@mushi-mushi/web 1.5.0 → 1.7.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.js CHANGED
@@ -338,6 +338,21 @@ function getWidgetStyles(theme) {
338
338
  outline: 2px solid ${vermillion};
339
339
  outline-offset: 3px;
340
340
  }
341
+ /* First-session welcome pulse. Three soft halos at 800ms each, then
342
+ auto-clear. Uses a box-shadow ring rather than transform/scale so it
343
+ can compose with the hover transform without fighting it. Respects
344
+ prefers-reduced-motion. */
345
+ @keyframes mushi-trigger-pulse {
346
+ 0% { box-shadow: 0 0 0 0 rgba(212, 67, 50, 0.55), 0 1px 0 ${rule}, 0 10px 24px -14px rgba(14,13,11,0.45); }
347
+ 70% { box-shadow: 0 0 0 16px rgba(212, 67, 50, 0), 0 1px 0 ${rule}, 0 10px 24px -14px rgba(14,13,11,0.45); }
348
+ 100% { box-shadow: 0 0 0 0 rgba(212, 67, 50, 0), 0 1px 0 ${rule}, 0 10px 24px -14px rgba(14,13,11,0.45); }
349
+ }
350
+ .mushi-trigger-pulse {
351
+ animation: mushi-trigger-pulse 800ms ${easeStamp} 3;
352
+ }
353
+ @media (prefers-reduced-motion: reduce) {
354
+ .mushi-trigger-pulse { animation: none; }
355
+ }
341
356
  .mushi-trigger.bottom-right {
342
357
  bottom: var(--mushi-bottom, calc(24px + env(safe-area-inset-bottom, 0px)));
343
358
  right: var(--mushi-right, calc(24px + env(safe-area-inset-right, 0px)));
@@ -608,6 +623,25 @@ function getWidgetStyles(theme) {
608
623
  transform: translateX(-4px);
609
624
  transition: opacity 220ms ${easeStamp}, transform 220ms ${easeStamp}, color 220ms ${easeStamp};
610
625
  }
626
+ /* Feature-request and Reports-inbox entries sit above the five
627
+ category cards as discoverable shortcuts. We give them a subtle
628
+ left rule so the eye reads them as a separate group rather than
629
+ "another category". The shortcut group has zero hover indent
630
+ overshoot \u2014 we want them quiet until intent. */
631
+ .mushi-feature-entry,
632
+ .mushi-reports-entry {
633
+ padding-left: 10px;
634
+ border-left: 2px solid ${inkFaint};
635
+ transition: padding 220ms ${easeStamp}, color 220ms ${easeStamp}, border-color 220ms ${easeStamp};
636
+ }
637
+ .mushi-feature-entry:hover,
638
+ .mushi-reports-entry:hover {
639
+ border-left-color: ${vermillion};
640
+ padding-left: 14px;
641
+ }
642
+ .mushi-feature-entry .mushi-option-icon {
643
+ filter: none;
644
+ }
611
645
  .mushi-report-row {
612
646
  width: 100%;
613
647
  display: grid;
@@ -1025,6 +1059,113 @@ function getWidgetStyles(theme) {
1025
1059
  color: ${inkMuted};
1026
1060
  }
1027
1061
 
1062
+ /* \u2500\u2500 Two-way receipt (success step) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1063
+ /* The receipt block sits below the stamp/meta. Three states: */
1064
+ /* 1. delivering... (spinner pill, while host onSubmit awaits) */
1065
+ /* 2. confirmed (Receipt #abc12345 + Track on Mushi link) */
1066
+ /* 3. queued offline (warn pill \u2014 degrade gracefully) */
1067
+ .mushi-success-receipt {
1068
+ margin-top: 14px;
1069
+ width: 100%;
1070
+ max-width: 280px;
1071
+ display: flex;
1072
+ flex-direction: column;
1073
+ gap: 6px;
1074
+ align-items: stretch;
1075
+ }
1076
+ .mushi-success-receipt-row {
1077
+ display: flex;
1078
+ align-items: center;
1079
+ justify-content: center;
1080
+ gap: 8px;
1081
+ font-family: ${fontMono};
1082
+ font-size: 11px;
1083
+ letter-spacing: 0.05em;
1084
+ color: ${inkMuted};
1085
+ }
1086
+ .mushi-success-receipt-label {
1087
+ text-transform: uppercase;
1088
+ letter-spacing: 0.10em;
1089
+ color: ${inkMuted};
1090
+ }
1091
+ .mushi-success-receipt-id {
1092
+ display: inline-flex;
1093
+ align-items: center;
1094
+ gap: 5px;
1095
+ padding: 3px 8px;
1096
+ border-radius: 4px;
1097
+ background: transparent;
1098
+ border: 1px dashed ${rule};
1099
+ color: inherit;
1100
+ font-family: ${fontMono};
1101
+ font-size: 12px;
1102
+ letter-spacing: 0.02em;
1103
+ cursor: pointer;
1104
+ transition: background 120ms ease, border-color 120ms ease;
1105
+ }
1106
+ .mushi-success-receipt-id:hover,
1107
+ .mushi-success-receipt-id:focus-visible {
1108
+ background: rgba(217, 65, 47, 0.06);
1109
+ border-color: ${vermillion};
1110
+ color: ${vermillion};
1111
+ outline: none;
1112
+ }
1113
+ .mushi-success-receipt-copy {
1114
+ font-size: 11px;
1115
+ opacity: 0.7;
1116
+ }
1117
+ .mushi-success-receipt-track {
1118
+ display: inline-flex;
1119
+ align-items: center;
1120
+ justify-content: center;
1121
+ gap: 4px;
1122
+ padding: 6px 10px;
1123
+ border-radius: 4px;
1124
+ background: ${vermillion};
1125
+ color: #fff;
1126
+ font-family: ${fontMono};
1127
+ font-size: 11px;
1128
+ letter-spacing: 0.10em;
1129
+ text-transform: uppercase;
1130
+ text-decoration: none;
1131
+ transition: filter 120ms ease;
1132
+ }
1133
+ .mushi-success-receipt-track:hover,
1134
+ .mushi-success-receipt-track:focus-visible {
1135
+ filter: brightness(0.95);
1136
+ outline: none;
1137
+ }
1138
+ .mushi-success-receipt-spinner {
1139
+ width: 11px;
1140
+ height: 11px;
1141
+ border-radius: 50%;
1142
+ border: 1.5px solid ${rule};
1143
+ border-top-color: ${vermillion};
1144
+ animation: mushi-receipt-spin 0.8s linear infinite;
1145
+ }
1146
+ @keyframes mushi-receipt-spin {
1147
+ to { transform: rotate(360deg); }
1148
+ }
1149
+ .mushi-success-receipt-hint {
1150
+ color: ${inkMuted};
1151
+ font-style: italic;
1152
+ }
1153
+ .mushi-success-receipt-warn {
1154
+ color: ${vermillion};
1155
+ }
1156
+ .mushi-success-sla {
1157
+ margin-top: 2px;
1158
+ font-family: ${fontDisplay};
1159
+ font-size: 12px;
1160
+ line-height: 1.45;
1161
+ text-align: center;
1162
+ color: ${inkMuted};
1163
+ max-width: 260px;
1164
+ }
1165
+ .mushi-success-sla-default {
1166
+ opacity: 0.85;
1167
+ }
1168
+
1028
1169
  @keyframes mushi-stamp-ring {
1029
1170
  to { stroke-dashoffset: 0; }
1030
1171
  }
@@ -1274,6 +1415,145 @@ function getWidgetStyles(theme) {
1274
1415
  font-size: 10.5px;
1275
1416
  }
1276
1417
 
1418
+ /* \u2500\u2500\u2500 Banner launcher (trigger: 'banner') \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1419
+
1420
+ .mushi-banner {
1421
+ position: fixed;
1422
+ left: 0;
1423
+ right: 0;
1424
+ height: 36px;
1425
+ display: flex;
1426
+ align-items: center;
1427
+ justify-content: center;
1428
+ gap: 10px;
1429
+ padding: 0 16px;
1430
+ font-family: ${fontMono};
1431
+ font-size: 11.5px;
1432
+ letter-spacing: 0.04em;
1433
+ white-space: nowrap;
1434
+ overflow: hidden;
1435
+ z-index: var(--mushi-banner-z, 99998);
1436
+ animation: mushi-banner-slide-in 0.3s ${easeStamp} both;
1437
+ }
1438
+
1439
+ .mushi-banner.top { top: 0; }
1440
+ .mushi-banner.bottom { bottom: 0; }
1441
+
1442
+ /* --- neon variant (electric lime \u2014 dev / beta tool aesthetic) --- */
1443
+ .mushi-banner.neon {
1444
+ background: #0FFF50;
1445
+ color: #0a1a0a;
1446
+ border-bottom: 1.5px solid #00C43A;
1447
+ }
1448
+ .mushi-banner.neon.bottom {
1449
+ border-top: 1.5px solid #00C43A;
1450
+ border-bottom: none;
1451
+ }
1452
+ .mushi-banner.neon .mushi-banner-btn {
1453
+ background: rgba(0,0,0,0.14);
1454
+ color: #0a1a0a;
1455
+ border: 1px solid rgba(0,0,0,0.22);
1456
+ }
1457
+ .mushi-banner.neon .mushi-banner-btn:hover {
1458
+ background: rgba(0,0,0,0.22);
1459
+ }
1460
+
1461
+ /* --- brand variant (vermillion \u2014 editorial, app-quality) --- */
1462
+ .mushi-banner.brand {
1463
+ background: ${vermillion};
1464
+ color: #fff;
1465
+ border-bottom: 1.5px solid ${isDark ? "#C4321E" : "#B52F1F"};
1466
+ }
1467
+ .mushi-banner.brand.bottom {
1468
+ border-top: 1.5px solid ${isDark ? "#C4321E" : "#B52F1F"};
1469
+ border-bottom: none;
1470
+ }
1471
+ .mushi-banner.brand .mushi-banner-btn {
1472
+ background: rgba(255,255,255,0.18);
1473
+ color: #fff;
1474
+ border: 1px solid rgba(255,255,255,0.32);
1475
+ }
1476
+ .mushi-banner.brand .mushi-banner-btn:hover {
1477
+ background: rgba(255,255,255,0.28);
1478
+ }
1479
+
1480
+ /* --- subtle variant (hairline, muted \u2014 least disruptive) --- */
1481
+ .mushi-banner.subtle {
1482
+ background: ${isDark ? "rgba(242,235,221,0.06)" : "rgba(14,13,11,0.04)"};
1483
+ color: ${inkMuted};
1484
+ border-bottom: 1px solid ${rule};
1485
+ }
1486
+ .mushi-banner.subtle.bottom {
1487
+ border-top: 1px solid ${rule};
1488
+ border-bottom: none;
1489
+ }
1490
+ .mushi-banner.subtle .mushi-banner-btn {
1491
+ background: ${isDark ? "rgba(242,235,221,0.10)" : "rgba(14,13,11,0.07)"};
1492
+ color: ${ink};
1493
+ border: 1px solid ${rule};
1494
+ }
1495
+ .mushi-banner.subtle .mushi-banner-btn:hover {
1496
+ background: ${isDark ? "rgba(242,235,221,0.16)" : "rgba(14,13,11,0.12)"};
1497
+ }
1498
+
1499
+ .mushi-banner-label {
1500
+ flex: 1;
1501
+ text-align: center;
1502
+ overflow: hidden;
1503
+ text-overflow: ellipsis;
1504
+ }
1505
+
1506
+ .mushi-banner-btn {
1507
+ display: inline-flex;
1508
+ align-items: center;
1509
+ gap: 4px;
1510
+ padding: 3px 10px;
1511
+ border-radius: 3px;
1512
+ cursor: pointer;
1513
+ font: inherit;
1514
+ letter-spacing: inherit;
1515
+ transition: background 0.15s ease, opacity 0.15s ease;
1516
+ flex-shrink: 0;
1517
+ height: 24px;
1518
+ line-height: 1;
1519
+ }
1520
+ .mushi-banner-btn:focus-visible {
1521
+ outline: 2px solid ${vermillion};
1522
+ outline-offset: 2px;
1523
+ }
1524
+
1525
+ .mushi-banner-dismiss {
1526
+ background: transparent !important;
1527
+ border: none !important;
1528
+ opacity: 0.65;
1529
+ cursor: pointer;
1530
+ font-size: 14px;
1531
+ line-height: 1;
1532
+ padding: 4px 8px;
1533
+ margin-left: auto;
1534
+ flex-shrink: 0;
1535
+ color: inherit;
1536
+ border-radius: 3px;
1537
+ transition: opacity 0.15s, background 0.15s;
1538
+ }
1539
+ .mushi-banner-dismiss:hover {
1540
+ opacity: 1;
1541
+ background: rgba(0,0,0,0.12) !important;
1542
+ }
1543
+ .mushi-banner.neon .mushi-banner-dismiss:hover { background: rgba(0,0,0,0.18) !important; }
1544
+
1545
+ @keyframes mushi-banner-slide-in {
1546
+ from { transform: translateY(calc(-1 * 100%)); opacity: 0.5; }
1547
+ to { transform: translateY(0); opacity: 1; }
1548
+ }
1549
+ .mushi-banner.bottom {
1550
+ animation-name: mushi-banner-slide-in-bottom;
1551
+ }
1552
+ @keyframes mushi-banner-slide-in-bottom {
1553
+ from { transform: translateY(100%); opacity: 0.5; }
1554
+ to { transform: translateY(0); opacity: 1; }
1555
+ }
1556
+
1277
1557
  @media (prefers-reduced-motion: reduce) {
1278
1558
  *,
1279
1559
  *::before,
@@ -1297,6 +1577,7 @@ var CATEGORY_ICONS = {
1297
1577
  confusing: "\u{1F615}",
1298
1578
  other: "\u{1F4DD}"
1299
1579
  };
1580
+ var FEATURE_REQUEST_INTENT = "Feature request";
1300
1581
  function pad2(n) {
1301
1582
  return n < 10 ? `0${n}` : String(n);
1302
1583
  }
@@ -1311,7 +1592,7 @@ function isSubmitShortcut(e) {
1311
1592
  function escapeHtml(value) {
1312
1593
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1313
1594
  }
1314
- var MushiWidget = class {
1595
+ var MushiWidget = class _MushiWidget {
1315
1596
  constructor(config = {}, callbacks, sdkVersion = "0.7.0") {
1316
1597
  this.sdkVersion = sdkVersion;
1317
1598
  this.config = {
@@ -1331,6 +1612,7 @@ var MushiWidget = class {
1331
1612
  locale: config.locale ?? "auto",
1332
1613
  zIndex: config.zIndex ?? 99999,
1333
1614
  trigger: config.trigger ?? "auto",
1615
+ bannerConfig: config.bannerConfig ?? {},
1334
1616
  attachToSelector: config.attachToSelector ?? "",
1335
1617
  inset: config.inset ?? {},
1336
1618
  respectSafeArea: config.respectSafeArea ?? true,
@@ -1342,7 +1624,12 @@ var MushiWidget = class {
1342
1624
  brandFooter: config.brandFooter ?? true,
1343
1625
  outdatedBanner: config.outdatedBanner ?? "auto",
1344
1626
  betaMode: config.betaMode ?? {},
1345
- minDescriptionLength: config.minDescriptionLength ?? 20
1627
+ minDescriptionLength: config.minDescriptionLength ?? 20,
1628
+ dashboardUrl: config.dashboardUrl ?? "",
1629
+ responseSlaLabel: config.responseSlaLabel ?? "",
1630
+ featureRequestCard: config.featureRequestCard ?? true,
1631
+ featureRequestLabel: config.featureRequestLabel ?? "",
1632
+ featureRequestDescription: config.featureRequestDescription ?? ""
1346
1633
  };
1347
1634
  this.callbacks = callbacks;
1348
1635
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
@@ -1360,6 +1647,13 @@ var MushiWidget = class {
1360
1647
  step = "category";
1361
1648
  selectedCategory = null;
1362
1649
  selectedIntent = null;
1650
+ /**
1651
+ * True when the user took the "Feature request" shortcut. We track this
1652
+ * separately from `selectedCategory='other'` so the Back button on the
1653
+ * details step jumps straight back to the category picker instead of
1654
+ * landing on the intent picker the user explicitly skipped.
1655
+ */
1656
+ viaFeatureRequest = false;
1363
1657
  screenshotAttached = false;
1364
1658
  screenshotCapturing = false;
1365
1659
  screenshotError = false;
@@ -1397,6 +1691,19 @@ var MushiWidget = class {
1397
1691
  successTimer = null;
1398
1692
  autoCloseTimer = null;
1399
1693
  rewardsState = null;
1694
+ /** Server-confirmed id for the just-submitted report. Surfaces in
1695
+ * the success step as a copyable receipt + optional deep link to
1696
+ * the Mushi console (when `dashboardUrl` is configured). Cleared
1697
+ * on every new `open()` so a re-opened widget never reuses a
1698
+ * stale id from the previous session. */
1699
+ lastReportId = null;
1700
+ /** True when the just-submitted report was queued offline (no
1701
+ * network, or the API errored and went into the retry queue).
1702
+ * Drives a different success copy so the user knows the report
1703
+ * hasn't actually reached the console yet. */
1704
+ lastSubmitQueuedOffline = false;
1705
+ /** Whether the user has clicked ✕ on the header banner this session. */
1706
+ bannerDismissed = false;
1400
1707
  mount() {
1401
1708
  if (this.host.isConnected) return;
1402
1709
  document.body.appendChild(this.host);
@@ -1431,7 +1738,12 @@ var MushiWidget = class {
1431
1738
  ...config.brandFooter !== void 0 ? { brandFooter: config.brandFooter } : {},
1432
1739
  ...config.outdatedBanner !== void 0 ? { outdatedBanner: config.outdatedBanner } : {},
1433
1740
  ...config.betaMode !== void 0 ? { betaMode: config.betaMode } : {},
1434
- ...config.minDescriptionLength !== void 0 ? { minDescriptionLength: config.minDescriptionLength } : {}
1741
+ ...config.minDescriptionLength !== void 0 ? { minDescriptionLength: config.minDescriptionLength } : {},
1742
+ ...config.dashboardUrl !== void 0 ? { dashboardUrl: config.dashboardUrl } : {},
1743
+ ...config.responseSlaLabel !== void 0 ? { responseSlaLabel: config.responseSlaLabel } : {},
1744
+ ...config.featureRequestCard !== void 0 ? { featureRequestCard: config.featureRequestCard } : {},
1745
+ ...config.featureRequestLabel !== void 0 ? { featureRequestLabel: config.featureRequestLabel } : {},
1746
+ ...config.featureRequestDescription !== void 0 ? { featureRequestDescription: config.featureRequestDescription } : {}
1435
1747
  };
1436
1748
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
1437
1749
  this.syncAttachedLaunchers();
@@ -1449,7 +1761,15 @@ var MushiWidget = class {
1449
1761
  this.submitting = false;
1450
1762
  this.submittedAt = null;
1451
1763
  this.removeSelectorHint();
1452
- if (options?.category) {
1764
+ this.lastReportId = null;
1765
+ this.lastSubmitQueuedOffline = false;
1766
+ this.viaFeatureRequest = false;
1767
+ if (options?.featureRequest) {
1768
+ this.selectedCategory = "other";
1769
+ this.selectedIntent = FEATURE_REQUEST_INTENT;
1770
+ this.viaFeatureRequest = true;
1771
+ this.step = "details";
1772
+ } else if (options?.category) {
1453
1773
  this.selectedCategory = options.category;
1454
1774
  this.selectedIntent = null;
1455
1775
  this.step = "intent";
@@ -1467,6 +1787,22 @@ var MushiWidget = class {
1467
1787
  this.render();
1468
1788
  this.callbacks.onClose();
1469
1789
  }
1790
+ /**
1791
+ * Briefly highlight the trigger button (a soft pulse + tooltip) without
1792
+ * opening the full reporter panel. Use for first-session welcome nudges
1793
+ * and other "by the way, this exists" prompts where forcing the panel
1794
+ * open would feel aggressive. Honours `position: 'none'` (no-op when
1795
+ * the trigger button is hidden).
1796
+ */
1797
+ pulseTrigger() {
1798
+ if (this.isOpen) return;
1799
+ const trigger = this.shadow.querySelector(".mushi-trigger");
1800
+ if (!trigger) return;
1801
+ trigger.classList.add("mushi-trigger-pulse");
1802
+ window.setTimeout(() => {
1803
+ trigger.classList.remove("mushi-trigger-pulse");
1804
+ }, 2400);
1805
+ }
1470
1806
  getIsOpen() {
1471
1807
  return this.isOpen;
1472
1808
  }
@@ -1676,7 +2012,7 @@ var MushiWidget = class {
1676
2012
  shouldRenderTrigger() {
1677
2013
  if (!this.triggerVisible) return false;
1678
2014
  if (this.triggerHiddenByScroll) return false;
1679
- if (this.config.trigger === "manual" || this.config.trigger === "hidden" || this.config.trigger === "attach") {
2015
+ if (this.config.trigger === "manual" || this.config.trigger === "hidden" || this.config.trigger === "attach" || this.config.trigger === "banner") {
1680
2016
  return false;
1681
2017
  }
1682
2018
  if (this.isMobileSmartHidden()) return false;
@@ -1685,6 +2021,81 @@ var MushiWidget = class {
1685
2021
  const action = this.config.environments[this.detectEnvironment()];
1686
2022
  return action !== "never" && action !== "manual";
1687
2023
  }
2024
+ /** Height of the banner in px — kept in sync with the CSS `.mushi-banner` height (36px). */
2025
+ static BANNER_HEIGHT = 36;
2026
+ /** CSS property applied to document.body so host-app content doesn't slide under the banner. */
2027
+ static BODY_NUDGE_PROP = "--mushi-banner-offset";
2028
+ applyBodyNudge(position) {
2029
+ const h = `${_MushiWidget.BANNER_HEIGHT}px`;
2030
+ if (position === "top") {
2031
+ document.documentElement.style.setProperty(_MushiWidget.BODY_NUDGE_PROP, h);
2032
+ if (!document.body.style.paddingTop) {
2033
+ document.body.style.paddingTop = h;
2034
+ document.body.dataset.mushiBannerNudged = "top";
2035
+ }
2036
+ } else {
2037
+ document.documentElement.style.setProperty(_MushiWidget.BODY_NUDGE_PROP, h);
2038
+ if (!document.body.style.paddingBottom) {
2039
+ document.body.style.paddingBottom = h;
2040
+ document.body.dataset.mushiBannerNudged = "bottom";
2041
+ }
2042
+ }
2043
+ }
2044
+ removeBodyNudge() {
2045
+ document.documentElement.style.removeProperty(_MushiWidget.BODY_NUDGE_PROP);
2046
+ const nudged = document.body.dataset.mushiBannerNudged;
2047
+ if (nudged === "top") {
2048
+ document.body.style.paddingTop = "";
2049
+ delete document.body.dataset.mushiBannerNudged;
2050
+ } else if (nudged === "bottom") {
2051
+ document.body.style.paddingBottom = "";
2052
+ delete document.body.dataset.mushiBannerNudged;
2053
+ }
2054
+ }
2055
+ renderBanner() {
2056
+ if (this.config.trigger !== "banner") return;
2057
+ if (this.bannerDismissed) {
2058
+ this.removeBodyNudge();
2059
+ return;
2060
+ }
2061
+ if (!this.triggerVisible) return;
2062
+ if (this.isRouteHidden()) return;
2063
+ const bc = this.config.bannerConfig ?? {};
2064
+ const variant = bc.variant ?? "brand";
2065
+ const position = bc.position ?? "top";
2066
+ const bugLabel = bc.bugCta ?? "\u{1F41B} Report a bug";
2067
+ const showFeat = bc.featureCta !== false;
2068
+ const featLabel = bc.featureCtaLabel ?? "\u2728 Request feature";
2069
+ const zIdx = bc.zIndex ?? (this.config.zIndex ?? 99999) - 1;
2070
+ const banner = document.createElement("div");
2071
+ banner.className = `mushi-banner ${variant} ${position}`;
2072
+ banner.style.setProperty("--mushi-banner-z", String(zIdx));
2073
+ banner.setAttribute("role", "banner");
2074
+ const bugBtn = document.createElement("button");
2075
+ bugBtn.className = "mushi-banner-btn";
2076
+ bugBtn.textContent = bugLabel;
2077
+ bugBtn.addEventListener("click", () => this.open());
2078
+ const dismissBtn = document.createElement("button");
2079
+ dismissBtn.className = "mushi-banner-dismiss";
2080
+ dismissBtn.textContent = "\u2715";
2081
+ dismissBtn.setAttribute("aria-label", "Dismiss feedback banner");
2082
+ dismissBtn.addEventListener("click", () => {
2083
+ this.bannerDismissed = true;
2084
+ this.removeBodyNudge();
2085
+ this.render();
2086
+ });
2087
+ banner.appendChild(bugBtn);
2088
+ if (showFeat) {
2089
+ const featBtn = document.createElement("button");
2090
+ featBtn.className = "mushi-banner-btn";
2091
+ featBtn.textContent = featLabel;
2092
+ featBtn.addEventListener("click", () => this.open({ featureRequest: true }));
2093
+ banner.appendChild(featBtn);
2094
+ }
2095
+ banner.appendChild(dismissBtn);
2096
+ this.shadow.appendChild(banner);
2097
+ this.applyBodyNudge(position);
2098
+ }
1688
2099
  effectiveTrigger() {
1689
2100
  if (!this.config.smartHide || typeof window === "undefined") return this.config.trigger;
1690
2101
  const smart = this.config.smartHide === true ? { onMobile: "edge-tab" } : this.config.smartHide;
@@ -1723,6 +2134,7 @@ var MushiWidget = class {
1723
2134
  const style = document.createElement("style");
1724
2135
  style.textContent = getWidgetStyles(theme);
1725
2136
  this.shadow.appendChild(style);
2137
+ this.renderBanner();
1726
2138
  if (this.shouldRenderTrigger()) {
1727
2139
  const effectiveTrigger = this.effectiveTrigger();
1728
2140
  const trigger = document.createElement("button");
@@ -1889,12 +2301,46 @@ var MushiWidget = class {
1889
2301
  </div>
1890
2302
  <span class="mushi-option-arrow" aria-hidden="true">\u2192</span>
1891
2303
  </button>
2304
+ ${this.renderFeatureRequestEntry()}
1892
2305
  ${categories}
1893
2306
  ${this.rewardsState ? this.renderRewardsNudge() : ""}
1894
2307
  </div>
1895
2308
  ${this.renderStepIndicator(STEP_NUMBER.category)}
1896
2309
  `;
1897
2310
  }
2311
+ /**
2312
+ * First-class "Feature request" entry rendered at the top of the
2313
+ * category step. Beta apps consistently get more useful signal when
2314
+ * the user has a no-friction path to say "I wish this did X" — burying
2315
+ * it as an intent under the "Other" category drops feature submissions
2316
+ * by ~40% in industry studies (Userpilot, Usersnap 2025).
2317
+ *
2318
+ * Wire format: still routes through the standard `other` category with
2319
+ * a `user_category = 'Feature request'` stamp, so we don't need a DB
2320
+ * migration. The admin console filters on that string to surface the
2321
+ * Feature-request swimlane.
2322
+ */
2323
+ renderFeatureRequestEntry() {
2324
+ const enabled = this.config.featureRequestCard !== false;
2325
+ if (!enabled) return "";
2326
+ const label = this.config.featureRequestLabel ?? "Feature request";
2327
+ const desc = this.config.featureRequestDescription ?? "Suggest something new \u2014 even rough ideas help us prioritise";
2328
+ return `
2329
+ <button
2330
+ type="button"
2331
+ class="mushi-option-btn mushi-feature-entry"
2332
+ data-action="feature-request"
2333
+ aria-label="${escapeHtml(label)}"
2334
+ >
2335
+ <span class="mushi-option-icon" aria-hidden="true">\u2728</span>
2336
+ <div class="mushi-option-text">
2337
+ <span class="mushi-option-label">${escapeHtml(label)}</span>
2338
+ <span class="mushi-option-desc">${escapeHtml(desc)}</span>
2339
+ </div>
2340
+ <span class="mushi-option-arrow" aria-hidden="true">\u2192</span>
2341
+ </button>
2342
+ `;
2343
+ }
1898
2344
  /** Collapsible "What's new" changelog row. Closes the reporter feedback loop. */
1899
2345
  renderBetaChangelog() {
1900
2346
  const entries = this.config.betaMode?.changelogItems;
@@ -2093,12 +2539,81 @@ var MushiWidget = class {
2093
2539
  </div>
2094
2540
  <div class="mushi-success-headline">${t.widget.submitted}</div>
2095
2541
  <div class="mushi-success-meta">REPORT \xB7 ${time}</div>
2542
+ ${this.renderSuccessReceipt()}
2096
2543
  ${this.rewardsState ? this.renderSuccessRewards() : ""}
2097
2544
  ${this.config.betaMode?.enabled ? this.renderBetaSuccessFooter() : ""}
2098
2545
  </div>
2099
2546
  </div>
2100
2547
  `;
2101
2548
  }
2549
+ /**
2550
+ * Two-way receipt block. Until the host's `onSubmit` resolves with a
2551
+ * server-confirmed report id, we show a discreet "delivering..." pill so
2552
+ * the user knows their submission is still in flight. Once we have the
2553
+ * id, we surface a short monospaced id + a copy button + an optional
2554
+ * "Track on Mushi" deep link to `dashboardUrl/reports/<id>` so the user
2555
+ * can watch the status walk through queued -> classified -> fixed in
2556
+ * real time (Peak-End rule: the last impression sticks). If we never
2557
+ * get an id (offline retry queue), we say so explicitly rather than
2558
+ * pretending everything is fine.
2559
+ */
2560
+ renderSuccessReceipt() {
2561
+ if (this.lastSubmitQueuedOffline) {
2562
+ return `
2563
+ <div class="mushi-success-receipt" role="status">
2564
+ <div class="mushi-success-receipt-row mushi-success-receipt-warn">
2565
+ <span class="mushi-success-receipt-label">Queued offline</span>
2566
+ <span class="mushi-success-receipt-hint">We&rsquo;ll send it the moment you&rsquo;re back online.</span>
2567
+ </div>
2568
+ </div>
2569
+ `;
2570
+ }
2571
+ if (!this.lastReportId) {
2572
+ return `
2573
+ <div class="mushi-success-receipt" role="status">
2574
+ <div class="mushi-success-receipt-row">
2575
+ <span class="mushi-success-receipt-spinner" aria-hidden="true"></span>
2576
+ <span class="mushi-success-receipt-hint">Delivering to the team\u2026</span>
2577
+ </div>
2578
+ ${this.renderSlaLine()}
2579
+ </div>
2580
+ `;
2581
+ }
2582
+ const idShort = `#${this.lastReportId.slice(0, 8)}`;
2583
+ const dashboard = (this.config.dashboardUrl ?? "").replace(/\/$/, "");
2584
+ const trackHref = dashboard ? `${dashboard}/reports/${encodeURIComponent(this.lastReportId)}` : "";
2585
+ return `
2586
+ <div class="mushi-success-receipt" role="status">
2587
+ <div class="mushi-success-receipt-row">
2588
+ <span class="mushi-success-receipt-label">Receipt</span>
2589
+ <button
2590
+ type="button"
2591
+ class="mushi-success-receipt-id"
2592
+ data-action="copy-report-id"
2593
+ data-copy-id="${escapeHtml(this.lastReportId)}"
2594
+ title="Copy report id ${escapeHtml(this.lastReportId)}"
2595
+ aria-label="Copy report id ${escapeHtml(this.lastReportId)}"
2596
+ >${escapeHtml(idShort)}<span class="mushi-success-receipt-copy" aria-hidden="true">\u2398</span></button>
2597
+ </div>
2598
+ ${trackHref ? `
2599
+ <a
2600
+ class="mushi-success-receipt-track"
2601
+ href="${escapeHtml(trackHref)}"
2602
+ target="_blank"
2603
+ rel="noopener noreferrer"
2604
+ >Track on Mushi <span aria-hidden="true">\u2197</span></a>
2605
+ ` : ""}
2606
+ ${this.renderSlaLine()}
2607
+ </div>
2608
+ `;
2609
+ }
2610
+ renderSlaLine() {
2611
+ const sla = (this.config.responseSlaLabel ?? "").trim();
2612
+ if (sla) {
2613
+ return `<div class="mushi-success-sla">${escapeHtml(sla)}</div>`;
2614
+ }
2615
+ return `<div class="mushi-success-sla mushi-success-sla-default">A human will look at this within a working day.</div>`;
2616
+ }
2102
2617
  /**
2103
2618
  * Reciprocity footer on the success step: closes the feedback loop by
2104
2619
  * attributing where the report goes, sets a response expectation, and
@@ -2190,8 +2705,15 @@ var MushiWidget = class {
2190
2705
  this.step = "category";
2191
2706
  this.selectedCategory = null;
2192
2707
  } else if (this.step === "details") {
2193
- this.step = "intent";
2194
- this.selectedIntent = null;
2708
+ if (this.viaFeatureRequest) {
2709
+ this.step = "category";
2710
+ this.selectedCategory = null;
2711
+ this.selectedIntent = null;
2712
+ this.viaFeatureRequest = false;
2713
+ } else {
2714
+ this.step = "intent";
2715
+ this.selectedIntent = null;
2716
+ }
2195
2717
  } else if (this.step === "reports") {
2196
2718
  this.step = "category";
2197
2719
  } else if (this.step === "report-detail") {
@@ -2203,6 +2725,13 @@ var MushiWidget = class {
2203
2725
  panel.querySelector('[data-action="reports"]')?.addEventListener("click", () => {
2204
2726
  void this.loadReporterReports();
2205
2727
  });
2728
+ panel.querySelector('[data-action="feature-request"]')?.addEventListener("click", () => {
2729
+ this.selectedCategory = "other";
2730
+ this.selectedIntent = FEATURE_REQUEST_INTENT;
2731
+ this.viaFeatureRequest = true;
2732
+ this.step = "details";
2733
+ this.render();
2734
+ });
2206
2735
  panel.querySelectorAll("[data-report-id]").forEach((btn) => {
2207
2736
  btn.addEventListener("click", () => {
2208
2737
  const reportId = btn.dataset.reportId;
@@ -2212,6 +2741,27 @@ var MushiWidget = class {
2212
2741
  panel.querySelector('[data-action="reporter-reply"]')?.addEventListener("click", () => {
2213
2742
  void this.submitReporterReply(panel);
2214
2743
  });
2744
+ panel.querySelector('[data-action="copy-report-id"]')?.addEventListener("click", (e) => {
2745
+ const btn = e.currentTarget;
2746
+ const id = btn.dataset.copyId;
2747
+ if (!id) return;
2748
+ const restore = btn.innerHTML;
2749
+ const done = () => {
2750
+ btn.innerHTML = "Copied \u2713";
2751
+ window.setTimeout(() => {
2752
+ if (btn.isConnected) btn.innerHTML = restore;
2753
+ }, 1600);
2754
+ };
2755
+ try {
2756
+ if (navigator.clipboard?.writeText) {
2757
+ void navigator.clipboard.writeText(id).then(done).catch(() => done());
2758
+ } else {
2759
+ done();
2760
+ }
2761
+ } catch {
2762
+ done();
2763
+ }
2764
+ });
2215
2765
  panel.querySelectorAll("[data-category]").forEach((btn) => {
2216
2766
  btn.addEventListener("click", () => {
2217
2767
  this.selectedCategory = btn.dataset.category;
@@ -2275,21 +2825,46 @@ var MushiWidget = class {
2275
2825
  }
2276
2826
  this.submitting = true;
2277
2827
  this.submittedAt = /* @__PURE__ */ new Date();
2828
+ this.lastReportId = null;
2829
+ this.lastSubmitQueuedOffline = false;
2278
2830
  this.render();
2279
- this.callbacks.onSubmit({
2280
- category: this.selectedCategory,
2281
- description,
2282
- intent: this.selectedIntent ?? void 0
2283
- });
2831
+ const outcomeP = (async () => {
2832
+ try {
2833
+ const ret = this.callbacks.onSubmit({
2834
+ category: this.selectedCategory,
2835
+ description,
2836
+ intent: this.selectedIntent ?? void 0
2837
+ });
2838
+ if (ret && typeof ret.then === "function") {
2839
+ const outcome = await ret;
2840
+ return outcome ?? null;
2841
+ }
2842
+ return null;
2843
+ } catch {
2844
+ return { reportId: null, queuedOffline: true };
2845
+ }
2846
+ })();
2284
2847
  this.successTimer = setTimeout(() => {
2285
2848
  this.successTimer = null;
2286
2849
  this.submitting = false;
2287
2850
  this.step = "success";
2288
2851
  this.render();
2289
- this.autoCloseTimer = setTimeout(() => {
2290
- this.autoCloseTimer = null;
2291
- if (this.step === "success") this.close();
2292
- }, 2800);
2852
+ void outcomeP.then((outcome) => {
2853
+ if (this.step !== "success") return;
2854
+ if (outcome) {
2855
+ this.lastReportId = outcome.reportId ?? null;
2856
+ this.lastSubmitQueuedOffline = Boolean(outcome.queuedOffline);
2857
+ this.render();
2858
+ }
2859
+ if (this.autoCloseTimer !== null) {
2860
+ clearTimeout(this.autoCloseTimer);
2861
+ }
2862
+ const closeDelayMs = this.lastReportId && this.config.dashboardUrl ? 6e3 : 2800;
2863
+ this.autoCloseTimer = setTimeout(() => {
2864
+ this.autoCloseTimer = null;
2865
+ if (this.step === "success") this.close();
2866
+ }, closeDelayMs);
2867
+ });
2293
2868
  }, 500);
2294
2869
  };
2295
2870
  panel.querySelector('[data-action="submit"]')?.addEventListener("click", submitReport);
@@ -2932,16 +3507,50 @@ function truncateUrl(url) {
2932
3507
  function createScreenshotCapture(options = {}) {
2933
3508
  let activeOptions = options;
2934
3509
  async function take() {
2935
- if (typeof document === "undefined") {
2936
- return { ok: false, reason: "unsupported", message: "Not in a browser context" };
2937
- }
2938
- const svgResult = await trySvgCapture(activeOptions.privacy);
2939
- if (svgResult.ok) return svgResult;
2940
- if (svgResult.reason !== "unsupported") {
2941
- const mediaResult = await tryDisplayMediaCapture();
2942
- if (mediaResult.ok) return mediaResult;
3510
+ try {
3511
+ if (typeof document === "undefined") return null;
3512
+ const canvas = document.createElement("canvas");
3513
+ const ctx = canvas.getContext("2d");
3514
+ if (!ctx) return null;
3515
+ const width = window.innerWidth;
3516
+ const height = window.innerHeight;
3517
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
3518
+ canvas.width = width * dpr;
3519
+ canvas.height = height * dpr;
3520
+ ctx.scale(dpr, dpr);
3521
+ const safeDocument = buildPrivacySafeDocument(activeOptions.privacy);
3522
+ const svgData = `
3523
+ <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
3524
+ <foreignObject width="100%" height="100%">
3525
+ <div xmlns="http://www.w3.org/1999/xhtml">
3526
+ ${new XMLSerializer().serializeToString(safeDocument)}
3527
+ </div>
3528
+ </foreignObject>
3529
+ </svg>
3530
+ `;
3531
+ const img = new Image();
3532
+ const blob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
3533
+ const url = URL.createObjectURL(blob);
3534
+ return new Promise((resolve) => {
3535
+ img.onload = () => {
3536
+ ctx.drawImage(img, 0, 0, width, height);
3537
+ URL.revokeObjectURL(url);
3538
+ try {
3539
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
3540
+ resolve(dataUrl);
3541
+ } catch {
3542
+ resolve(null);
3543
+ }
3544
+ };
3545
+ img.onerror = () => {
3546
+ URL.revokeObjectURL(url);
3547
+ resolve(null);
3548
+ };
3549
+ img.src = url;
3550
+ });
3551
+ } catch {
3552
+ return null;
2943
3553
  }
2944
- return svgResult;
2945
3554
  }
2946
3555
  return {
2947
3556
  take,
@@ -2950,108 +3559,16 @@ function createScreenshotCapture(options = {}) {
2950
3559
  }
2951
3560
  };
2952
3561
  }
2953
- async function trySvgCapture(privacy) {
2954
- try {
2955
- const canvas = document.createElement("canvas");
2956
- const ctx = canvas.getContext("2d");
2957
- if (!ctx) return { ok: false, reason: "unsupported", message: "Canvas 2d context unavailable" };
2958
- const width = window.innerWidth;
2959
- const height = window.innerHeight;
2960
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
2961
- canvas.width = width * dpr;
2962
- canvas.height = height * dpr;
2963
- ctx.scale(dpr, dpr);
2964
- const safeDocument = buildPrivacySafeDocument(privacy);
2965
- const svgData = `
2966
- <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
2967
- <foreignObject width="100%" height="100%">
2968
- <div xmlns="http://www.w3.org/1999/xhtml">
2969
- ${new XMLSerializer().serializeToString(safeDocument)}
2970
- </div>
2971
- </foreignObject>
2972
- </svg>
2973
- `;
2974
- const img = new Image();
2975
- const blob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
2976
- const url = URL.createObjectURL(blob);
2977
- const loadResult = await new Promise((resolve) => {
2978
- img.onload = () => resolve("loaded");
2979
- img.onerror = () => resolve("error");
2980
- const timeout = setTimeout(() => resolve("error"), 5e3);
2981
- img.onload = () => {
2982
- clearTimeout(timeout);
2983
- resolve("loaded");
2984
- };
2985
- });
2986
- URL.revokeObjectURL(url);
2987
- if (loadResult === "error") {
2988
- return { ok: false, reason: "load-error", message: "SVG image load failed" };
2989
- }
2990
- ctx.drawImage(img, 0, 0, width, height);
2991
- try {
2992
- const dataUrl = canvas.toDataURL("image/jpeg", 0.75);
2993
- return { ok: true, dataUrl };
2994
- } catch (err) {
2995
- const message = err instanceof Error ? err.message : String(err);
2996
- return { ok: false, reason: "tainted", message };
2997
- }
2998
- } catch (err) {
2999
- return { ok: false, reason: "error", message: err instanceof Error ? err.message : String(err) };
3000
- }
3001
- }
3002
- async function tryDisplayMediaCapture() {
3003
- if (typeof navigator === "undefined" || !("mediaDevices" in navigator)) {
3004
- return { ok: false, reason: "unsupported", message: "mediaDevices not available" };
3005
- }
3006
- const mediaDevices = navigator.mediaDevices;
3007
- if (typeof mediaDevices.getDisplayMedia !== "function") {
3008
- return { ok: false, reason: "unsupported", message: "getDisplayMedia not available" };
3009
- }
3010
- let stream = null;
3011
- try {
3012
- stream = await mediaDevices.getDisplayMedia({
3013
- video: { displaySurface: "browser" },
3014
- audio: false
3015
- });
3016
- const track = stream.getVideoTracks()[0];
3017
- if (!track) return { ok: false, reason: "error", message: "No video track" };
3018
- const imageCapture = new window.ImageCapture(track);
3019
- const bitmap = await imageCapture.grabFrame();
3020
- const canvas = document.createElement("canvas");
3021
- canvas.width = bitmap.width;
3022
- canvas.height = bitmap.height;
3023
- const ctx = canvas.getContext("2d");
3024
- ctx.drawImage(bitmap, 0, 0);
3025
- bitmap.close();
3026
- const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
3027
- return { ok: true, dataUrl };
3028
- } catch (err) {
3029
- const message = err instanceof Error ? err.message : String(err);
3030
- if (err instanceof Error && err.name === "NotAllowedError") {
3031
- return { ok: false, reason: "cancelled", message };
3032
- }
3033
- return { ok: false, reason: "error", message };
3034
- } finally {
3035
- stream?.getTracks().forEach((t) => t.stop());
3036
- }
3037
- }
3562
+ var DEFAULT_REDACT_SELECTORS = [
3563
+ 'input[type="password"]',
3564
+ "[data-mushi-redact]"
3565
+ ];
3038
3566
  function buildPrivacySafeDocument(privacy) {
3039
3567
  const clone = document.documentElement.cloneNode(true);
3040
- for (const img of Array.from(clone.querySelectorAll("img[src]"))) {
3041
- const src = img.getAttribute("src") ?? "";
3042
- try {
3043
- const url = new URL(src, window.location.href);
3044
- if (url.origin !== window.location.origin) {
3045
- img.removeAttribute("src");
3046
- img.removeAttribute("srcset");
3047
- }
3048
- } catch {
3049
- }
3050
- }
3051
- for (const el of Array.from(clone.querySelectorAll("[style]"))) {
3052
- const style = el.getAttribute("style") ?? "";
3053
- if (/url\(["']?https?:\/\/(?!localhost)/.test(style)) {
3054
- el.setAttribute("style", style.replace(/url\([^)]*\)/g, "none"));
3568
+ const redactSelectors = privacy?.redactSelectors !== void 0 ? privacy.redactSelectors : DEFAULT_REDACT_SELECTORS;
3569
+ for (const selector of redactSelectors) {
3570
+ for (const el of safeQueryAll(clone, selector)) {
3571
+ redactElement(el);
3055
3572
  }
3056
3573
  }
3057
3574
  for (const selector of privacy?.blockSelectors ?? []) {
@@ -3073,6 +3590,19 @@ function safeQueryAll(root, selector) {
3073
3590
  return [];
3074
3591
  }
3075
3592
  }
3593
+ function redactElement(el) {
3594
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
3595
+ el.value = "";
3596
+ el.setAttribute("value", "");
3597
+ }
3598
+ el.textContent = "";
3599
+ el.setAttribute(
3600
+ "style",
3601
+ `${el.getAttribute("style") ?? ""};background:#000!important;color:#000!important;text-shadow:none!important;border-color:#000!important;`
3602
+ );
3603
+ el.setAttribute("data-mushi-redacted", "true");
3604
+ while (el.firstChild) el.removeChild(el.firstChild);
3605
+ }
3076
3606
  function maskElement(el) {
3077
3607
  if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
3078
3608
  el.value = "";
@@ -3852,6 +4382,13 @@ function tagSentryScope(reportId, options = {}) {
3852
4382
  }
3853
4383
 
3854
4384
  // src/proactive-triggers.ts
4385
+ var DEFAULT_EXCLUDE_ROUTES = [
4386
+ "/login",
4387
+ "/logout",
4388
+ "/signup",
4389
+ "/sso/*",
4390
+ "/auth/*"
4391
+ ];
3855
4392
  function setupProactiveTriggers(callbacks, config = {}) {
3856
4393
  const cleanups = [];
3857
4394
  if (config.rageClick !== false) {
@@ -3922,6 +4459,83 @@ function setupProactiveTriggers(callbacks, config = {}) {
3922
4459
  globalThis.fetch = origFetch;
3923
4460
  });
3924
4461
  }
4462
+ const pageDwellEnabled = config.pageDwell === true || typeof config.pageDwell === "object" && config.pageDwell !== null;
4463
+ if (pageDwellEnabled && typeof window !== "undefined") {
4464
+ let isExcluded2 = function(path) {
4465
+ return excludeRoutes.some((pattern) => {
4466
+ if (pattern.endsWith("/*")) {
4467
+ return path.startsWith(pattern.slice(0, -2));
4468
+ }
4469
+ return path === pattern || path.startsWith(pattern + "/");
4470
+ });
4471
+ }, fire2 = function() {
4472
+ const path = window.location?.pathname ?? "";
4473
+ if (isExcluded2(path)) return;
4474
+ callbacks.onTrigger("page_dwell", { thresholdMs, path });
4475
+ }, arm2 = function() {
4476
+ if (timer) clearTimeout(timer);
4477
+ const path = window.location?.pathname ?? "";
4478
+ if (!isExcluded2(path)) {
4479
+ timer = setTimeout(fire2, thresholdMs);
4480
+ }
4481
+ }, reset2 = function() {
4482
+ const path = window.location?.pathname ?? "";
4483
+ if (path !== lastPath) {
4484
+ lastPath = path;
4485
+ arm2();
4486
+ }
4487
+ };
4488
+ const dwellCfg = typeof config.pageDwell === "object" ? config.pageDwell ?? {} : {};
4489
+ const thresholdMs = dwellCfg.thresholdMs || 5 * 60 * 1e3;
4490
+ const excludeRoutes = dwellCfg.excludeRoutes !== void 0 ? dwellCfg.excludeRoutes : DEFAULT_EXCLUDE_ROUTES;
4491
+ let timer = null;
4492
+ let lastPath = window.location?.pathname ?? "";
4493
+ arm2();
4494
+ const history2 = window.history;
4495
+ const origPush = history2?.pushState;
4496
+ const origReplace = history2?.replaceState;
4497
+ if (history2 && origPush && origReplace) {
4498
+ history2.pushState = function(...args) {
4499
+ const result = origPush.apply(this, args);
4500
+ reset2();
4501
+ return result;
4502
+ };
4503
+ history2.replaceState = function(...args) {
4504
+ const result = origReplace.apply(this, args);
4505
+ reset2();
4506
+ return result;
4507
+ };
4508
+ }
4509
+ const onPop = () => reset2();
4510
+ window.addEventListener("popstate", onPop);
4511
+ cleanups.push(() => {
4512
+ if (timer) clearTimeout(timer);
4513
+ window.removeEventListener("popstate", onPop);
4514
+ if (history2 && origPush) history2.pushState = origPush;
4515
+ if (history2 && origReplace) history2.replaceState = origReplace;
4516
+ });
4517
+ }
4518
+ const firstSessionEnabled = config.firstSession === true || typeof config.firstSession === "object" && config.firstSession !== null;
4519
+ if (firstSessionEnabled && typeof window !== "undefined") {
4520
+ const opts = typeof config.firstSession === "object" ? config.firstSession ?? {} : {};
4521
+ const delayMs = opts.delayMs ?? 45 * 1e3;
4522
+ const storageKey = opts.storageKey ?? (config.projectId ? `mushi:${config.projectId}:firstSessionShown` : "mushi:firstSessionShown");
4523
+ let alreadyShown = false;
4524
+ try {
4525
+ alreadyShown = window.localStorage?.getItem(storageKey) === "1";
4526
+ } catch {
4527
+ }
4528
+ if (!alreadyShown) {
4529
+ const timer = setTimeout(() => {
4530
+ try {
4531
+ window.localStorage?.setItem(storageKey, "1");
4532
+ } catch {
4533
+ }
4534
+ callbacks.onTrigger("first_session", { delayMs });
4535
+ }, delayMs);
4536
+ cleanups.push(() => clearTimeout(timer));
4537
+ }
4538
+ }
3925
4539
  if (config.errorBoundary) {
3926
4540
  let handleError2 = function(event) {
3927
4541
  callbacks.onTrigger("error_boundary", {
@@ -4025,7 +4639,7 @@ function createProactiveManager(config = {}) {
4025
4639
 
4026
4640
  // src/version.ts
4027
4641
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/web";
4028
- var MUSHI_SDK_VERSION = "1.5.0" ;
4642
+ var MUSHI_SDK_VERSION = "1.7.0" ;
4029
4643
 
4030
4644
  // src/mushi.ts
4031
4645
  var instance = null;
@@ -4210,7 +4824,8 @@ function createInstance(config) {
4210
4824
  onSubmit: async ({ category, description, intent }) => {
4211
4825
  log.info("Report submitted", { category, intent });
4212
4826
  proactiveManager?.recordSubmission();
4213
- await submitReport(category, description, intent);
4827
+ const outcome = await submitReport(category, description, intent);
4828
+ return outcome ?? { reportId: null, queuedOffline: true };
4214
4829
  },
4215
4830
  onOpen: () => {
4216
4831
  log.debug("Widget opened");
@@ -4230,21 +4845,8 @@ function createInstance(config) {
4230
4845
  onScreenshotRequest: async () => {
4231
4846
  if (!screenshotCap || activeConfig.capture?.screenshot === "off") return;
4232
4847
  log.debug("Taking screenshot");
4233
- widget.setScreenshotCapturing(true);
4234
- const result = await screenshotCap.take();
4235
- if (result.ok) {
4236
- pendingScreenshot = result.dataUrl;
4237
- widget.setScreenshotAttached(true);
4238
- log.debug("Screenshot captured");
4239
- } else {
4240
- pendingScreenshot = null;
4241
- if (result.reason !== "cancelled") {
4242
- widget.setScreenshotError(true);
4243
- log.debug("Screenshot failed", { reason: result.reason, message: result.message });
4244
- } else {
4245
- widget.setScreenshotCapturing(false);
4246
- }
4247
- }
4848
+ pendingScreenshot = await screenshotCap.take();
4849
+ widget.setScreenshotAttached(pendingScreenshot !== null);
4248
4850
  },
4249
4851
  onScreenshotRemove: () => {
4250
4852
  log.debug("Screenshot attachment removed");
@@ -4295,7 +4897,7 @@ function createInstance(config) {
4295
4897
  let proactiveTriggers = null;
4296
4898
  let proactiveManager = null;
4297
4899
  const proactiveCfg = activeConfig.proactive;
4298
- const hasAnyProactive = proactiveCfg && (proactiveCfg.rageClick !== false || proactiveCfg.longTask !== false || proactiveCfg.apiCascade !== false || proactiveCfg.errorBoundary === true);
4900
+ const hasAnyProactive = proactiveCfg && (proactiveCfg.rageClick !== false || proactiveCfg.longTask !== false || proactiveCfg.apiCascade !== false || proactiveCfg.errorBoundary === true || Boolean(proactiveCfg.pageDwell) || Boolean(proactiveCfg.firstSession));
4299
4901
  if (hasAnyProactive && typeof document !== "undefined") {
4300
4902
  proactiveManager = createProactiveManager(proactiveCfg?.cooldown);
4301
4903
  proactiveTriggers = setupProactiveTriggers(
@@ -4308,7 +4910,11 @@ function createInstance(config) {
4308
4910
  log.info("Proactive trigger fired", { type, context });
4309
4911
  pendingProactiveTrigger = type;
4310
4912
  emit("proactive:triggered", { type, context });
4311
- widget.open();
4913
+ if (type === "first_session") {
4914
+ widget.pulseTrigger?.();
4915
+ } else {
4916
+ widget.open();
4917
+ }
4312
4918
  }
4313
4919
  },
4314
4920
  {
@@ -4316,14 +4922,19 @@ function createInstance(config) {
4316
4922
  longTask: proactiveCfg?.longTask,
4317
4923
  apiCascade: proactiveCfg?.apiCascade,
4318
4924
  apiEndpoint: resolveApiEndpoint(activeConfig),
4319
- errorBoundary: proactiveCfg?.errorBoundary
4925
+ errorBoundary: proactiveCfg?.errorBoundary,
4926
+ pageDwell: proactiveCfg?.pageDwell,
4927
+ firstSession: proactiveCfg?.firstSession,
4928
+ projectId: bootstrapConfig.projectId
4320
4929
  }
4321
4930
  );
4322
4931
  log.debug("Proactive triggers enabled", {
4323
4932
  rageClick: proactiveCfg?.rageClick !== false,
4324
4933
  longTask: proactiveCfg?.longTask !== false,
4325
4934
  apiCascade: proactiveCfg?.apiCascade !== false,
4326
- errorBoundary: proactiveCfg?.errorBoundary === true
4935
+ errorBoundary: proactiveCfg?.errorBoundary === true,
4936
+ pageDwell: Boolean(proactiveCfg?.pageDwell),
4937
+ firstSession: Boolean(proactiveCfg?.firstSession)
4327
4938
  });
4328
4939
  }
4329
4940
  offlineQueue.startAutoSync(apiClient2);
@@ -4397,7 +5008,7 @@ function createInstance(config) {
4397
5008
  const filterResult = preFilter.check(description);
4398
5009
  if (!filterResult.passed) {
4399
5010
  log.info("Report blocked by pre-filter", { reason: filterResult.reason });
4400
- return;
5011
+ return void 0;
4401
5012
  }
4402
5013
  const wasm = config.preFilter?.wasmClassifier;
4403
5014
  if (wasm) {
@@ -4418,7 +5029,7 @@ function createInstance(config) {
4418
5029
  confidence: verdict.confidence,
4419
5030
  reason: verdict.reason
4420
5031
  });
4421
- return;
5032
+ return void 0;
4422
5033
  }
4423
5034
  log.debug("On-device classifier verdict", { ...verdict });
4424
5035
  } catch (err) {
@@ -4429,7 +5040,7 @@ function createInstance(config) {
4429
5040
  }
4430
5041
  if (!rateLimiter.tryConsume()) {
4431
5042
  log.warn("Report throttled \u2014 rate limit exceeded");
4432
- return;
5043
+ return void 0;
4433
5044
  }
4434
5045
  const scrubbedDescription = piiScrubber.scrub(preFilter.truncate(description));
4435
5046
  const sentryCtx = config.sentry ? captureSentryContext(config.sentry) : void 0;
@@ -4567,6 +5178,11 @@ function createInstance(config) {
4567
5178
  pendingScreenshot = null;
4568
5179
  pendingElement = null;
4569
5180
  pendingProactiveTrigger = null;
5181
+ if (result?.ok) {
5182
+ const serverId = result.data?.reportId ?? report.id;
5183
+ return { reportId: serverId, queuedOffline: false };
5184
+ }
5185
+ return { reportId: null, queuedOffline: true };
4570
5186
  }
4571
5187
  const sdk = {
4572
5188
  report(options) {
@@ -4818,6 +5434,9 @@ function createInstance(config) {
4818
5434
  recordActivity(action, metadata) {
4819
5435
  if (!activeConfig.rewards?.enabled) return;
4820
5436
  enqueue({ action, metadata });
5437
+ },
5438
+ pulseTrigger() {
5439
+ widget.pulseTrigger?.();
4821
5440
  }
4822
5441
  };
4823
5442
  if (typeof globalThis !== "undefined" && (bootstrapConfig.debug ?? false)) {
@@ -4844,7 +5463,7 @@ function createInstance(config) {
4844
5463
  }
4845
5464
  if (typeof bootstrapConfig.onCrashedLastRun === "function") {
4846
5465
  try {
4847
- bootstrapConfig.onCrashedLastRun({ crashed });
5466
+ bootstrapConfig.onCrashedLastRun(crashed);
4848
5467
  } catch (err) {
4849
5468
  log.warn("onCrashedLastRun hook threw", {
4850
5469
  error: err instanceof Error ? err.message : String(err)
@@ -4856,13 +5475,26 @@ function createInstance(config) {
4856
5475
  }
4857
5476
  function mergeRuntimeConfig(config, runtime) {
4858
5477
  const nativeTrigger = runtime.native?.triggerMode;
4859
- const widgetTrigger = runtime.widget?.trigger ?? (nativeTrigger === "none" || nativeTrigger === "shake" ? "manual" : void 0);
5478
+ const runtimeLauncher = runtime.widget?.launcher;
5479
+ const widgetTrigger = runtimeLauncher ?? runtime.widget?.trigger ?? (nativeTrigger === "none" || nativeTrigger === "shake" ? "manual" : void 0);
5480
+ const runtimeBannerVariant = runtime.widget?.bannerVariant;
5481
+ const runtimeBannerPosition = runtime.widget?.bannerPosition;
5482
+ const runtimeBannerBugCta = runtime.widget?.bannerBugCta;
5483
+ const runtimeBannerFeatureCta = runtime.widget?.bannerFeatureCta;
5484
+ const derivedBannerConfig = runtimeBannerVariant || runtimeBannerPosition || runtimeBannerBugCta != null || runtimeBannerFeatureCta != null ? {
5485
+ ...config.widget?.bannerConfig ?? {},
5486
+ ...runtimeBannerVariant ? { variant: runtimeBannerVariant } : {},
5487
+ ...runtimeBannerPosition ? { position: runtimeBannerPosition } : {},
5488
+ ...runtimeBannerBugCta != null ? { bugCta: runtimeBannerBugCta ?? void 0 } : {},
5489
+ ...runtimeBannerFeatureCta != null ? { featureCta: runtimeBannerFeatureCta } : {}
5490
+ } : void 0;
4860
5491
  return {
4861
5492
  ...config,
4862
5493
  widget: {
4863
5494
  ...config.widget,
4864
5495
  ...runtime.widget,
4865
5496
  ...widgetTrigger ? { trigger: widgetTrigger } : {},
5497
+ ...derivedBannerConfig ? { bannerConfig: derivedBannerConfig } : {},
4866
5498
  // betaMode is local-only: set by the host app, not the dashboard.
4867
5499
  // Restore it after the runtime spread so it is never silently cleared.
4868
5500
  ...config.widget?.betaMode ? { betaMode: config.widget.betaMode } : {}
@@ -5105,6 +5737,8 @@ function createNoopInstance() {
5105
5737
  getReputation: async () => null,
5106
5738
  getTier: async () => null,
5107
5739
  recordActivity: () => {
5740
+ },
5741
+ pulseTrigger: () => {
5108
5742
  }
5109
5743
  };
5110
5744
  }