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