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