@mushi-mushi/web 1.5.0 → 1.6.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
  }
@@ -1297,6 +1438,7 @@ var CATEGORY_ICONS = {
1297
1438
  confusing: "\u{1F615}",
1298
1439
  other: "\u{1F4DD}"
1299
1440
  };
1441
+ var FEATURE_REQUEST_INTENT = "Feature request";
1300
1442
  function pad2(n) {
1301
1443
  return n < 10 ? `0${n}` : String(n);
1302
1444
  }
@@ -1342,7 +1484,12 @@ var MushiWidget = class {
1342
1484
  brandFooter: config.brandFooter ?? true,
1343
1485
  outdatedBanner: config.outdatedBanner ?? "auto",
1344
1486
  betaMode: config.betaMode ?? {},
1345
- minDescriptionLength: config.minDescriptionLength ?? 20
1487
+ minDescriptionLength: config.minDescriptionLength ?? 20,
1488
+ dashboardUrl: config.dashboardUrl ?? "",
1489
+ responseSlaLabel: config.responseSlaLabel ?? "",
1490
+ featureRequestCard: config.featureRequestCard ?? true,
1491
+ featureRequestLabel: config.featureRequestLabel ?? "",
1492
+ featureRequestDescription: config.featureRequestDescription ?? ""
1346
1493
  };
1347
1494
  this.callbacks = callbacks;
1348
1495
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
@@ -1360,6 +1507,13 @@ var MushiWidget = class {
1360
1507
  step = "category";
1361
1508
  selectedCategory = null;
1362
1509
  selectedIntent = null;
1510
+ /**
1511
+ * True when the user took the "Feature request" shortcut. We track this
1512
+ * separately from `selectedCategory='other'` so the Back button on the
1513
+ * details step jumps straight back to the category picker instead of
1514
+ * landing on the intent picker the user explicitly skipped.
1515
+ */
1516
+ viaFeatureRequest = false;
1363
1517
  screenshotAttached = false;
1364
1518
  screenshotCapturing = false;
1365
1519
  screenshotError = false;
@@ -1397,6 +1551,17 @@ var MushiWidget = class {
1397
1551
  successTimer = null;
1398
1552
  autoCloseTimer = null;
1399
1553
  rewardsState = null;
1554
+ /** Server-confirmed id for the just-submitted report. Surfaces in
1555
+ * the success step as a copyable receipt + optional deep link to
1556
+ * the Mushi console (when `dashboardUrl` is configured). Cleared
1557
+ * on every new `open()` so a re-opened widget never reuses a
1558
+ * stale id from the previous session. */
1559
+ lastReportId = null;
1560
+ /** True when the just-submitted report was queued offline (no
1561
+ * network, or the API errored and went into the retry queue).
1562
+ * Drives a different success copy so the user knows the report
1563
+ * hasn't actually reached the console yet. */
1564
+ lastSubmitQueuedOffline = false;
1400
1565
  mount() {
1401
1566
  if (this.host.isConnected) return;
1402
1567
  document.body.appendChild(this.host);
@@ -1431,7 +1596,12 @@ var MushiWidget = class {
1431
1596
  ...config.brandFooter !== void 0 ? { brandFooter: config.brandFooter } : {},
1432
1597
  ...config.outdatedBanner !== void 0 ? { outdatedBanner: config.outdatedBanner } : {},
1433
1598
  ...config.betaMode !== void 0 ? { betaMode: config.betaMode } : {},
1434
- ...config.minDescriptionLength !== void 0 ? { minDescriptionLength: config.minDescriptionLength } : {}
1599
+ ...config.minDescriptionLength !== void 0 ? { minDescriptionLength: config.minDescriptionLength } : {},
1600
+ ...config.dashboardUrl !== void 0 ? { dashboardUrl: config.dashboardUrl } : {},
1601
+ ...config.responseSlaLabel !== void 0 ? { responseSlaLabel: config.responseSlaLabel } : {},
1602
+ ...config.featureRequestCard !== void 0 ? { featureRequestCard: config.featureRequestCard } : {},
1603
+ ...config.featureRequestLabel !== void 0 ? { featureRequestLabel: config.featureRequestLabel } : {},
1604
+ ...config.featureRequestDescription !== void 0 ? { featureRequestDescription: config.featureRequestDescription } : {}
1435
1605
  };
1436
1606
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
1437
1607
  this.syncAttachedLaunchers();
@@ -1449,7 +1619,15 @@ var MushiWidget = class {
1449
1619
  this.submitting = false;
1450
1620
  this.submittedAt = null;
1451
1621
  this.removeSelectorHint();
1452
- if (options?.category) {
1622
+ this.lastReportId = null;
1623
+ this.lastSubmitQueuedOffline = false;
1624
+ this.viaFeatureRequest = false;
1625
+ if (options?.featureRequest) {
1626
+ this.selectedCategory = "other";
1627
+ this.selectedIntent = FEATURE_REQUEST_INTENT;
1628
+ this.viaFeatureRequest = true;
1629
+ this.step = "details";
1630
+ } else if (options?.category) {
1453
1631
  this.selectedCategory = options.category;
1454
1632
  this.selectedIntent = null;
1455
1633
  this.step = "intent";
@@ -1467,6 +1645,22 @@ var MushiWidget = class {
1467
1645
  this.render();
1468
1646
  this.callbacks.onClose();
1469
1647
  }
1648
+ /**
1649
+ * Briefly highlight the trigger button (a soft pulse + tooltip) without
1650
+ * opening the full reporter panel. Use for first-session welcome nudges
1651
+ * and other "by the way, this exists" prompts where forcing the panel
1652
+ * open would feel aggressive. Honours `position: 'none'` (no-op when
1653
+ * the trigger button is hidden).
1654
+ */
1655
+ pulseTrigger() {
1656
+ if (this.isOpen) return;
1657
+ const trigger = this.shadow.querySelector(".mushi-trigger");
1658
+ if (!trigger) return;
1659
+ trigger.classList.add("mushi-trigger-pulse");
1660
+ window.setTimeout(() => {
1661
+ trigger.classList.remove("mushi-trigger-pulse");
1662
+ }, 2400);
1663
+ }
1470
1664
  getIsOpen() {
1471
1665
  return this.isOpen;
1472
1666
  }
@@ -1889,12 +2083,46 @@ var MushiWidget = class {
1889
2083
  </div>
1890
2084
  <span class="mushi-option-arrow" aria-hidden="true">\u2192</span>
1891
2085
  </button>
2086
+ ${this.renderFeatureRequestEntry()}
1892
2087
  ${categories}
1893
2088
  ${this.rewardsState ? this.renderRewardsNudge() : ""}
1894
2089
  </div>
1895
2090
  ${this.renderStepIndicator(STEP_NUMBER.category)}
1896
2091
  `;
1897
2092
  }
2093
+ /**
2094
+ * First-class "Feature request" entry rendered at the top of the
2095
+ * category step. Beta apps consistently get more useful signal when
2096
+ * the user has a no-friction path to say "I wish this did X" — burying
2097
+ * it as an intent under the "Other" category drops feature submissions
2098
+ * by ~40% in industry studies (Userpilot, Usersnap 2025).
2099
+ *
2100
+ * Wire format: still routes through the standard `other` category with
2101
+ * a `user_category = 'Feature request'` stamp, so we don't need a DB
2102
+ * migration. The admin console filters on that string to surface the
2103
+ * Feature-request swimlane.
2104
+ */
2105
+ renderFeatureRequestEntry() {
2106
+ const enabled = this.config.featureRequestCard !== false;
2107
+ if (!enabled) return "";
2108
+ const label = this.config.featureRequestLabel ?? "Feature request";
2109
+ const desc = this.config.featureRequestDescription ?? "Suggest something new \u2014 even rough ideas help us prioritise";
2110
+ return `
2111
+ <button
2112
+ type="button"
2113
+ class="mushi-option-btn mushi-feature-entry"
2114
+ data-action="feature-request"
2115
+ aria-label="${escapeHtml(label)}"
2116
+ >
2117
+ <span class="mushi-option-icon" aria-hidden="true">\u2728</span>
2118
+ <div class="mushi-option-text">
2119
+ <span class="mushi-option-label">${escapeHtml(label)}</span>
2120
+ <span class="mushi-option-desc">${escapeHtml(desc)}</span>
2121
+ </div>
2122
+ <span class="mushi-option-arrow" aria-hidden="true">\u2192</span>
2123
+ </button>
2124
+ `;
2125
+ }
1898
2126
  /** Collapsible "What's new" changelog row. Closes the reporter feedback loop. */
1899
2127
  renderBetaChangelog() {
1900
2128
  const entries = this.config.betaMode?.changelogItems;
@@ -2093,12 +2321,81 @@ var MushiWidget = class {
2093
2321
  </div>
2094
2322
  <div class="mushi-success-headline">${t.widget.submitted}</div>
2095
2323
  <div class="mushi-success-meta">REPORT \xB7 ${time}</div>
2324
+ ${this.renderSuccessReceipt()}
2096
2325
  ${this.rewardsState ? this.renderSuccessRewards() : ""}
2097
2326
  ${this.config.betaMode?.enabled ? this.renderBetaSuccessFooter() : ""}
2098
2327
  </div>
2099
2328
  </div>
2100
2329
  `;
2101
2330
  }
2331
+ /**
2332
+ * Two-way receipt block. Until the host's `onSubmit` resolves with a
2333
+ * server-confirmed report id, we show a discreet "delivering..." pill so
2334
+ * the user knows their submission is still in flight. Once we have the
2335
+ * id, we surface a short monospaced id + a copy button + an optional
2336
+ * "Track on Mushi" deep link to `dashboardUrl/reports/<id>` so the user
2337
+ * can watch the status walk through queued -> classified -> fixed in
2338
+ * real time (Peak-End rule: the last impression sticks). If we never
2339
+ * get an id (offline retry queue), we say so explicitly rather than
2340
+ * pretending everything is fine.
2341
+ */
2342
+ renderSuccessReceipt() {
2343
+ if (this.lastSubmitQueuedOffline) {
2344
+ return `
2345
+ <div class="mushi-success-receipt" role="status">
2346
+ <div class="mushi-success-receipt-row mushi-success-receipt-warn">
2347
+ <span class="mushi-success-receipt-label">Queued offline</span>
2348
+ <span class="mushi-success-receipt-hint">We&rsquo;ll send it the moment you&rsquo;re back online.</span>
2349
+ </div>
2350
+ </div>
2351
+ `;
2352
+ }
2353
+ if (!this.lastReportId) {
2354
+ return `
2355
+ <div class="mushi-success-receipt" role="status">
2356
+ <div class="mushi-success-receipt-row">
2357
+ <span class="mushi-success-receipt-spinner" aria-hidden="true"></span>
2358
+ <span class="mushi-success-receipt-hint">Delivering to the team\u2026</span>
2359
+ </div>
2360
+ ${this.renderSlaLine()}
2361
+ </div>
2362
+ `;
2363
+ }
2364
+ const idShort = `#${this.lastReportId.slice(0, 8)}`;
2365
+ const dashboard = (this.config.dashboardUrl ?? "").replace(/\/$/, "");
2366
+ const trackHref = dashboard ? `${dashboard}/reports/${encodeURIComponent(this.lastReportId)}` : "";
2367
+ return `
2368
+ <div class="mushi-success-receipt" role="status">
2369
+ <div class="mushi-success-receipt-row">
2370
+ <span class="mushi-success-receipt-label">Receipt</span>
2371
+ <button
2372
+ type="button"
2373
+ class="mushi-success-receipt-id"
2374
+ data-action="copy-report-id"
2375
+ data-copy-id="${escapeHtml(this.lastReportId)}"
2376
+ title="Copy report id ${escapeHtml(this.lastReportId)}"
2377
+ aria-label="Copy report id ${escapeHtml(this.lastReportId)}"
2378
+ >${escapeHtml(idShort)}<span class="mushi-success-receipt-copy" aria-hidden="true">\u2398</span></button>
2379
+ </div>
2380
+ ${trackHref ? `
2381
+ <a
2382
+ class="mushi-success-receipt-track"
2383
+ href="${escapeHtml(trackHref)}"
2384
+ target="_blank"
2385
+ rel="noopener noreferrer"
2386
+ >Track on Mushi <span aria-hidden="true">\u2197</span></a>
2387
+ ` : ""}
2388
+ ${this.renderSlaLine()}
2389
+ </div>
2390
+ `;
2391
+ }
2392
+ renderSlaLine() {
2393
+ const sla = (this.config.responseSlaLabel ?? "").trim();
2394
+ if (sla) {
2395
+ return `<div class="mushi-success-sla">${escapeHtml(sla)}</div>`;
2396
+ }
2397
+ return `<div class="mushi-success-sla mushi-success-sla-default">A human will look at this within a working day.</div>`;
2398
+ }
2102
2399
  /**
2103
2400
  * Reciprocity footer on the success step: closes the feedback loop by
2104
2401
  * attributing where the report goes, sets a response expectation, and
@@ -2190,8 +2487,15 @@ var MushiWidget = class {
2190
2487
  this.step = "category";
2191
2488
  this.selectedCategory = null;
2192
2489
  } else if (this.step === "details") {
2193
- this.step = "intent";
2194
- this.selectedIntent = null;
2490
+ if (this.viaFeatureRequest) {
2491
+ this.step = "category";
2492
+ this.selectedCategory = null;
2493
+ this.selectedIntent = null;
2494
+ this.viaFeatureRequest = false;
2495
+ } else {
2496
+ this.step = "intent";
2497
+ this.selectedIntent = null;
2498
+ }
2195
2499
  } else if (this.step === "reports") {
2196
2500
  this.step = "category";
2197
2501
  } else if (this.step === "report-detail") {
@@ -2203,6 +2507,13 @@ var MushiWidget = class {
2203
2507
  panel.querySelector('[data-action="reports"]')?.addEventListener("click", () => {
2204
2508
  void this.loadReporterReports();
2205
2509
  });
2510
+ panel.querySelector('[data-action="feature-request"]')?.addEventListener("click", () => {
2511
+ this.selectedCategory = "other";
2512
+ this.selectedIntent = FEATURE_REQUEST_INTENT;
2513
+ this.viaFeatureRequest = true;
2514
+ this.step = "details";
2515
+ this.render();
2516
+ });
2206
2517
  panel.querySelectorAll("[data-report-id]").forEach((btn) => {
2207
2518
  btn.addEventListener("click", () => {
2208
2519
  const reportId = btn.dataset.reportId;
@@ -2212,6 +2523,27 @@ var MushiWidget = class {
2212
2523
  panel.querySelector('[data-action="reporter-reply"]')?.addEventListener("click", () => {
2213
2524
  void this.submitReporterReply(panel);
2214
2525
  });
2526
+ panel.querySelector('[data-action="copy-report-id"]')?.addEventListener("click", (e) => {
2527
+ const btn = e.currentTarget;
2528
+ const id = btn.dataset.copyId;
2529
+ if (!id) return;
2530
+ const restore = btn.innerHTML;
2531
+ const done = () => {
2532
+ btn.innerHTML = "Copied \u2713";
2533
+ window.setTimeout(() => {
2534
+ if (btn.isConnected) btn.innerHTML = restore;
2535
+ }, 1600);
2536
+ };
2537
+ try {
2538
+ if (navigator.clipboard?.writeText) {
2539
+ void navigator.clipboard.writeText(id).then(done).catch(() => done());
2540
+ } else {
2541
+ done();
2542
+ }
2543
+ } catch {
2544
+ done();
2545
+ }
2546
+ });
2215
2547
  panel.querySelectorAll("[data-category]").forEach((btn) => {
2216
2548
  btn.addEventListener("click", () => {
2217
2549
  this.selectedCategory = btn.dataset.category;
@@ -2275,21 +2607,46 @@ var MushiWidget = class {
2275
2607
  }
2276
2608
  this.submitting = true;
2277
2609
  this.submittedAt = /* @__PURE__ */ new Date();
2610
+ this.lastReportId = null;
2611
+ this.lastSubmitQueuedOffline = false;
2278
2612
  this.render();
2279
- this.callbacks.onSubmit({
2280
- category: this.selectedCategory,
2281
- description,
2282
- intent: this.selectedIntent ?? void 0
2283
- });
2613
+ const outcomeP = (async () => {
2614
+ try {
2615
+ const ret = this.callbacks.onSubmit({
2616
+ category: this.selectedCategory,
2617
+ description,
2618
+ intent: this.selectedIntent ?? void 0
2619
+ });
2620
+ if (ret && typeof ret.then === "function") {
2621
+ const outcome = await ret;
2622
+ return outcome ?? null;
2623
+ }
2624
+ return null;
2625
+ } catch {
2626
+ return { reportId: null, queuedOffline: true };
2627
+ }
2628
+ })();
2284
2629
  this.successTimer = setTimeout(() => {
2285
2630
  this.successTimer = null;
2286
2631
  this.submitting = false;
2287
2632
  this.step = "success";
2288
2633
  this.render();
2289
- this.autoCloseTimer = setTimeout(() => {
2290
- this.autoCloseTimer = null;
2291
- if (this.step === "success") this.close();
2292
- }, 2800);
2634
+ void outcomeP.then((outcome) => {
2635
+ if (this.step !== "success") return;
2636
+ if (outcome) {
2637
+ this.lastReportId = outcome.reportId ?? null;
2638
+ this.lastSubmitQueuedOffline = Boolean(outcome.queuedOffline);
2639
+ this.render();
2640
+ }
2641
+ if (this.autoCloseTimer !== null) {
2642
+ clearTimeout(this.autoCloseTimer);
2643
+ }
2644
+ const closeDelayMs = this.lastReportId && this.config.dashboardUrl ? 6e3 : 2800;
2645
+ this.autoCloseTimer = setTimeout(() => {
2646
+ this.autoCloseTimer = null;
2647
+ if (this.step === "success") this.close();
2648
+ }, closeDelayMs);
2649
+ });
2293
2650
  }, 500);
2294
2651
  };
2295
2652
  panel.querySelector('[data-action="submit"]')?.addEventListener("click", submitReport);
@@ -2932,16 +3289,50 @@ function truncateUrl(url) {
2932
3289
  function createScreenshotCapture(options = {}) {
2933
3290
  let activeOptions = options;
2934
3291
  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;
3292
+ try {
3293
+ if (typeof document === "undefined") return null;
3294
+ const canvas = document.createElement("canvas");
3295
+ const ctx = canvas.getContext("2d");
3296
+ if (!ctx) return null;
3297
+ const width = window.innerWidth;
3298
+ const height = window.innerHeight;
3299
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
3300
+ canvas.width = width * dpr;
3301
+ canvas.height = height * dpr;
3302
+ ctx.scale(dpr, dpr);
3303
+ const safeDocument = buildPrivacySafeDocument(activeOptions.privacy);
3304
+ const svgData = `
3305
+ <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
3306
+ <foreignObject width="100%" height="100%">
3307
+ <div xmlns="http://www.w3.org/1999/xhtml">
3308
+ ${new XMLSerializer().serializeToString(safeDocument)}
3309
+ </div>
3310
+ </foreignObject>
3311
+ </svg>
3312
+ `;
3313
+ const img = new Image();
3314
+ const blob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
3315
+ const url = URL.createObjectURL(blob);
3316
+ return new Promise((resolve) => {
3317
+ img.onload = () => {
3318
+ ctx.drawImage(img, 0, 0, width, height);
3319
+ URL.revokeObjectURL(url);
3320
+ try {
3321
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
3322
+ resolve(dataUrl);
3323
+ } catch {
3324
+ resolve(null);
3325
+ }
3326
+ };
3327
+ img.onerror = () => {
3328
+ URL.revokeObjectURL(url);
3329
+ resolve(null);
3330
+ };
3331
+ img.src = url;
3332
+ });
3333
+ } catch {
3334
+ return null;
2943
3335
  }
2944
- return svgResult;
2945
3336
  }
2946
3337
  return {
2947
3338
  take,
@@ -2950,108 +3341,16 @@ function createScreenshotCapture(options = {}) {
2950
3341
  }
2951
3342
  };
2952
3343
  }
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
- }
3344
+ var DEFAULT_REDACT_SELECTORS = [
3345
+ 'input[type="password"]',
3346
+ "[data-mushi-redact]"
3347
+ ];
3038
3348
  function buildPrivacySafeDocument(privacy) {
3039
3349
  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"));
3350
+ const redactSelectors = privacy?.redactSelectors !== void 0 ? privacy.redactSelectors : DEFAULT_REDACT_SELECTORS;
3351
+ for (const selector of redactSelectors) {
3352
+ for (const el of safeQueryAll(clone, selector)) {
3353
+ redactElement(el);
3055
3354
  }
3056
3355
  }
3057
3356
  for (const selector of privacy?.blockSelectors ?? []) {
@@ -3073,6 +3372,19 @@ function safeQueryAll(root, selector) {
3073
3372
  return [];
3074
3373
  }
3075
3374
  }
3375
+ function redactElement(el) {
3376
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
3377
+ el.value = "";
3378
+ el.setAttribute("value", "");
3379
+ }
3380
+ el.textContent = "";
3381
+ el.setAttribute(
3382
+ "style",
3383
+ `${el.getAttribute("style") ?? ""};background:#000!important;color:#000!important;text-shadow:none!important;border-color:#000!important;`
3384
+ );
3385
+ el.setAttribute("data-mushi-redacted", "true");
3386
+ while (el.firstChild) el.removeChild(el.firstChild);
3387
+ }
3076
3388
  function maskElement(el) {
3077
3389
  if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
3078
3390
  el.value = "";
@@ -3852,6 +4164,13 @@ function tagSentryScope(reportId, options = {}) {
3852
4164
  }
3853
4165
 
3854
4166
  // src/proactive-triggers.ts
4167
+ var DEFAULT_EXCLUDE_ROUTES = [
4168
+ "/login",
4169
+ "/logout",
4170
+ "/signup",
4171
+ "/sso/*",
4172
+ "/auth/*"
4173
+ ];
3855
4174
  function setupProactiveTriggers(callbacks, config = {}) {
3856
4175
  const cleanups = [];
3857
4176
  if (config.rageClick !== false) {
@@ -3922,6 +4241,83 @@ function setupProactiveTriggers(callbacks, config = {}) {
3922
4241
  globalThis.fetch = origFetch;
3923
4242
  });
3924
4243
  }
4244
+ const pageDwellEnabled = config.pageDwell === true || typeof config.pageDwell === "object" && config.pageDwell !== null;
4245
+ if (pageDwellEnabled && typeof window !== "undefined") {
4246
+ let isExcluded2 = function(path) {
4247
+ return excludeRoutes.some((pattern) => {
4248
+ if (pattern.endsWith("/*")) {
4249
+ return path.startsWith(pattern.slice(0, -2));
4250
+ }
4251
+ return path === pattern || path.startsWith(pattern + "/");
4252
+ });
4253
+ }, fire2 = function() {
4254
+ const path = window.location?.pathname ?? "";
4255
+ if (isExcluded2(path)) return;
4256
+ callbacks.onTrigger("page_dwell", { thresholdMs, path });
4257
+ }, arm2 = function() {
4258
+ if (timer) clearTimeout(timer);
4259
+ const path = window.location?.pathname ?? "";
4260
+ if (!isExcluded2(path)) {
4261
+ timer = setTimeout(fire2, thresholdMs);
4262
+ }
4263
+ }, reset2 = function() {
4264
+ const path = window.location?.pathname ?? "";
4265
+ if (path !== lastPath) {
4266
+ lastPath = path;
4267
+ arm2();
4268
+ }
4269
+ };
4270
+ const dwellCfg = typeof config.pageDwell === "object" ? config.pageDwell ?? {} : {};
4271
+ const thresholdMs = dwellCfg.thresholdMs || 5 * 60 * 1e3;
4272
+ const excludeRoutes = dwellCfg.excludeRoutes !== void 0 ? dwellCfg.excludeRoutes : DEFAULT_EXCLUDE_ROUTES;
4273
+ let timer = null;
4274
+ let lastPath = window.location?.pathname ?? "";
4275
+ arm2();
4276
+ const history2 = window.history;
4277
+ const origPush = history2?.pushState;
4278
+ const origReplace = history2?.replaceState;
4279
+ if (history2 && origPush && origReplace) {
4280
+ history2.pushState = function(...args) {
4281
+ const result = origPush.apply(this, args);
4282
+ reset2();
4283
+ return result;
4284
+ };
4285
+ history2.replaceState = function(...args) {
4286
+ const result = origReplace.apply(this, args);
4287
+ reset2();
4288
+ return result;
4289
+ };
4290
+ }
4291
+ const onPop = () => reset2();
4292
+ window.addEventListener("popstate", onPop);
4293
+ cleanups.push(() => {
4294
+ if (timer) clearTimeout(timer);
4295
+ window.removeEventListener("popstate", onPop);
4296
+ if (history2 && origPush) history2.pushState = origPush;
4297
+ if (history2 && origReplace) history2.replaceState = origReplace;
4298
+ });
4299
+ }
4300
+ const firstSessionEnabled = config.firstSession === true || typeof config.firstSession === "object" && config.firstSession !== null;
4301
+ if (firstSessionEnabled && typeof window !== "undefined") {
4302
+ const opts = typeof config.firstSession === "object" ? config.firstSession ?? {} : {};
4303
+ const delayMs = opts.delayMs ?? 45 * 1e3;
4304
+ const storageKey = opts.storageKey ?? (config.projectId ? `mushi:${config.projectId}:firstSessionShown` : "mushi:firstSessionShown");
4305
+ let alreadyShown = false;
4306
+ try {
4307
+ alreadyShown = window.localStorage?.getItem(storageKey) === "1";
4308
+ } catch {
4309
+ }
4310
+ if (!alreadyShown) {
4311
+ const timer = setTimeout(() => {
4312
+ try {
4313
+ window.localStorage?.setItem(storageKey, "1");
4314
+ } catch {
4315
+ }
4316
+ callbacks.onTrigger("first_session", { delayMs });
4317
+ }, delayMs);
4318
+ cleanups.push(() => clearTimeout(timer));
4319
+ }
4320
+ }
3925
4321
  if (config.errorBoundary) {
3926
4322
  let handleError2 = function(event) {
3927
4323
  callbacks.onTrigger("error_boundary", {
@@ -4025,7 +4421,7 @@ function createProactiveManager(config = {}) {
4025
4421
 
4026
4422
  // src/version.ts
4027
4423
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/web";
4028
- var MUSHI_SDK_VERSION = "1.5.0" ;
4424
+ var MUSHI_SDK_VERSION = "1.6.0" ;
4029
4425
 
4030
4426
  // src/mushi.ts
4031
4427
  var instance = null;
@@ -4210,7 +4606,8 @@ function createInstance(config) {
4210
4606
  onSubmit: async ({ category, description, intent }) => {
4211
4607
  log.info("Report submitted", { category, intent });
4212
4608
  proactiveManager?.recordSubmission();
4213
- await submitReport(category, description, intent);
4609
+ const outcome = await submitReport(category, description, intent);
4610
+ return outcome ?? { reportId: null, queuedOffline: true };
4214
4611
  },
4215
4612
  onOpen: () => {
4216
4613
  log.debug("Widget opened");
@@ -4230,21 +4627,8 @@ function createInstance(config) {
4230
4627
  onScreenshotRequest: async () => {
4231
4628
  if (!screenshotCap || activeConfig.capture?.screenshot === "off") return;
4232
4629
  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
- }
4630
+ pendingScreenshot = await screenshotCap.take();
4631
+ widget.setScreenshotAttached(pendingScreenshot !== null);
4248
4632
  },
4249
4633
  onScreenshotRemove: () => {
4250
4634
  log.debug("Screenshot attachment removed");
@@ -4295,7 +4679,7 @@ function createInstance(config) {
4295
4679
  let proactiveTriggers = null;
4296
4680
  let proactiveManager = null;
4297
4681
  const proactiveCfg = activeConfig.proactive;
4298
- const hasAnyProactive = proactiveCfg && (proactiveCfg.rageClick !== false || proactiveCfg.longTask !== false || proactiveCfg.apiCascade !== false || proactiveCfg.errorBoundary === true);
4682
+ const hasAnyProactive = proactiveCfg && (proactiveCfg.rageClick !== false || proactiveCfg.longTask !== false || proactiveCfg.apiCascade !== false || proactiveCfg.errorBoundary === true || Boolean(proactiveCfg.pageDwell) || Boolean(proactiveCfg.firstSession));
4299
4683
  if (hasAnyProactive && typeof document !== "undefined") {
4300
4684
  proactiveManager = createProactiveManager(proactiveCfg?.cooldown);
4301
4685
  proactiveTriggers = setupProactiveTriggers(
@@ -4308,7 +4692,11 @@ function createInstance(config) {
4308
4692
  log.info("Proactive trigger fired", { type, context });
4309
4693
  pendingProactiveTrigger = type;
4310
4694
  emit("proactive:triggered", { type, context });
4311
- widget.open();
4695
+ if (type === "first_session") {
4696
+ widget.pulseTrigger?.();
4697
+ } else {
4698
+ widget.open();
4699
+ }
4312
4700
  }
4313
4701
  },
4314
4702
  {
@@ -4316,14 +4704,19 @@ function createInstance(config) {
4316
4704
  longTask: proactiveCfg?.longTask,
4317
4705
  apiCascade: proactiveCfg?.apiCascade,
4318
4706
  apiEndpoint: resolveApiEndpoint(activeConfig),
4319
- errorBoundary: proactiveCfg?.errorBoundary
4707
+ errorBoundary: proactiveCfg?.errorBoundary,
4708
+ pageDwell: proactiveCfg?.pageDwell,
4709
+ firstSession: proactiveCfg?.firstSession,
4710
+ projectId: bootstrapConfig.projectId
4320
4711
  }
4321
4712
  );
4322
4713
  log.debug("Proactive triggers enabled", {
4323
4714
  rageClick: proactiveCfg?.rageClick !== false,
4324
4715
  longTask: proactiveCfg?.longTask !== false,
4325
4716
  apiCascade: proactiveCfg?.apiCascade !== false,
4326
- errorBoundary: proactiveCfg?.errorBoundary === true
4717
+ errorBoundary: proactiveCfg?.errorBoundary === true,
4718
+ pageDwell: Boolean(proactiveCfg?.pageDwell),
4719
+ firstSession: Boolean(proactiveCfg?.firstSession)
4327
4720
  });
4328
4721
  }
4329
4722
  offlineQueue.startAutoSync(apiClient2);
@@ -4397,7 +4790,7 @@ function createInstance(config) {
4397
4790
  const filterResult = preFilter.check(description);
4398
4791
  if (!filterResult.passed) {
4399
4792
  log.info("Report blocked by pre-filter", { reason: filterResult.reason });
4400
- return;
4793
+ return void 0;
4401
4794
  }
4402
4795
  const wasm = config.preFilter?.wasmClassifier;
4403
4796
  if (wasm) {
@@ -4418,7 +4811,7 @@ function createInstance(config) {
4418
4811
  confidence: verdict.confidence,
4419
4812
  reason: verdict.reason
4420
4813
  });
4421
- return;
4814
+ return void 0;
4422
4815
  }
4423
4816
  log.debug("On-device classifier verdict", { ...verdict });
4424
4817
  } catch (err) {
@@ -4429,7 +4822,7 @@ function createInstance(config) {
4429
4822
  }
4430
4823
  if (!rateLimiter.tryConsume()) {
4431
4824
  log.warn("Report throttled \u2014 rate limit exceeded");
4432
- return;
4825
+ return void 0;
4433
4826
  }
4434
4827
  const scrubbedDescription = piiScrubber.scrub(preFilter.truncate(description));
4435
4828
  const sentryCtx = config.sentry ? captureSentryContext(config.sentry) : void 0;
@@ -4567,6 +4960,11 @@ function createInstance(config) {
4567
4960
  pendingScreenshot = null;
4568
4961
  pendingElement = null;
4569
4962
  pendingProactiveTrigger = null;
4963
+ if (result?.ok) {
4964
+ const serverId = result.data?.reportId ?? report.id;
4965
+ return { reportId: serverId, queuedOffline: false };
4966
+ }
4967
+ return { reportId: null, queuedOffline: true };
4570
4968
  }
4571
4969
  const sdk = {
4572
4970
  report(options) {
@@ -4818,6 +5216,9 @@ function createInstance(config) {
4818
5216
  recordActivity(action, metadata) {
4819
5217
  if (!activeConfig.rewards?.enabled) return;
4820
5218
  enqueue({ action, metadata });
5219
+ },
5220
+ pulseTrigger() {
5221
+ widget.pulseTrigger?.();
4821
5222
  }
4822
5223
  };
4823
5224
  if (typeof globalThis !== "undefined" && (bootstrapConfig.debug ?? false)) {
@@ -4844,7 +5245,7 @@ function createInstance(config) {
4844
5245
  }
4845
5246
  if (typeof bootstrapConfig.onCrashedLastRun === "function") {
4846
5247
  try {
4847
- bootstrapConfig.onCrashedLastRun({ crashed });
5248
+ bootstrapConfig.onCrashedLastRun(crashed);
4848
5249
  } catch (err) {
4849
5250
  log.warn("onCrashedLastRun hook threw", {
4850
5251
  error: err instanceof Error ? err.message : String(err)
@@ -5105,6 +5506,8 @@ function createNoopInstance() {
5105
5506
  getReputation: async () => null,
5106
5507
  getTier: async () => null,
5107
5508
  recordActivity: () => {
5509
+ },
5510
+ pulseTrigger: () => {
5108
5511
  }
5109
5512
  };
5110
5513
  }