@mushi-mushi/web 0.1.0 → 0.2.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/README.md CHANGED
@@ -32,6 +32,13 @@ Auto-detects conditions that should prompt the user:
32
32
  - **Rage click** — 3+ clicks in < 500ms on same element
33
33
  - **Long task** — > 5s main thread block (PerformanceObserver)
34
34
  - **API cascade** — 3+ failed requests in 10s window
35
+ - **Error boundary** — global `window.error` and `unhandledrejection` events (opt-in via `errorBoundary: true`)
36
+
37
+ Each trigger respects its config flag — set `rageClick: false` to disable rage click detection, etc.
38
+
39
+ ## Known Limitations
40
+
41
+ **Screenshot capture** uses canvas/SVG `foreignObject` serialization. This does not work with cross-origin iframes, tainted `<canvas>` elements, or pages with strict CSP. Best-effort on single-origin SPAs.
35
42
 
36
43
  ## Bundle Size
37
44
 
@@ -52,6 +59,28 @@ Mushi.init({
52
59
 
53
60
  ### With Proactive Triggers
54
61
 
62
+ Proactive triggers are wired into `Mushi.init()` automatically when `config.proactive` is provided. The SDK opens the widget when a trigger fires, gated by fatigue prevention:
63
+
64
+ ```typescript
65
+ Mushi.init({
66
+ projectId: 'proj_xxx',
67
+ apiKey: 'your-api-key',
68
+ proactive: {
69
+ rageClick: true,
70
+ longTask: true,
71
+ apiCascade: true,
72
+ errorBoundary: true,
73
+ cooldown: {
74
+ maxProactivePerSession: 2,
75
+ dismissCooldownHours: 24,
76
+ suppressAfterDismissals: 3,
77
+ },
78
+ },
79
+ });
80
+ ```
81
+
82
+ For manual composition (advanced), the lower-level APIs are also exported:
83
+
55
84
  ```typescript
56
85
  import { createProactiveManager, setupProactiveTriggers } from '@mushi-mushi/web';
57
86
 
@@ -60,7 +89,7 @@ const manager = createProactiveManager({ maxProactivePerSession: 2 });
60
89
  setupProactiveTriggers({
61
90
  onTrigger: (type, context) => {
62
91
  if (manager.shouldShow(type)) {
63
- // Open widget with pre-filled context
92
+ // Custom handling
64
93
  }
65
94
  },
66
95
  });
package/dist/index.cjs CHANGED
@@ -551,15 +551,21 @@ var MushiWidget = class {
551
551
  document.body.appendChild(this.host);
552
552
  this.render();
553
553
  }
554
- open() {
554
+ open(options) {
555
555
  if (this.isOpen) return;
556
556
  this.isOpen = true;
557
- this.step = "category";
558
- this.selectedCategory = null;
559
- this.selectedIntent = null;
560
557
  this.screenshotAttached = false;
561
558
  this.elementSelected = false;
562
559
  this.submitting = false;
560
+ if (options?.category) {
561
+ this.selectedCategory = options.category;
562
+ this.selectedIntent = null;
563
+ this.step = "intent";
564
+ } else {
565
+ this.selectedCategory = null;
566
+ this.selectedIntent = null;
567
+ this.step = "category";
568
+ }
563
569
  this.render();
564
570
  this.callbacks.onOpen();
565
571
  }
@@ -775,9 +781,10 @@ var MushiWidget = class {
775
781
  const textarea = panel.querySelector(".mushi-textarea");
776
782
  const description = textarea?.value?.trim() ?? "";
777
783
  const errorEl = panel.querySelector(".mushi-error");
778
- if (description.length < 5) {
784
+ const MIN_DESCRIPTION_LENGTH = 20;
785
+ if (description.length < MIN_DESCRIPTION_LENGTH) {
779
786
  if (errorEl) {
780
- errorEl.textContent = t.widget.error;
787
+ errorEl.textContent = `${t.widget.error} (${description.length}/${MIN_DESCRIPTION_LENGTH})`;
781
788
  errorEl.style.display = "block";
782
789
  }
783
790
  return;
@@ -1199,6 +1206,167 @@ function captureSentryContext(_config) {
1199
1206
  return context;
1200
1207
  }
1201
1208
 
1209
+ // src/proactive-triggers.ts
1210
+ function setupProactiveTriggers(callbacks, config = {}) {
1211
+ const cleanups = [];
1212
+ if (config.rageClick !== false) {
1213
+ let handleClick2 = function(e) {
1214
+ const now = Date.now();
1215
+ if (e.target === lastClickTarget) {
1216
+ clickTimes.push(now);
1217
+ clickTimes = clickTimes.filter((t) => now - t < 500);
1218
+ if (clickTimes.length >= 3) {
1219
+ const el = e.target;
1220
+ callbacks.onTrigger("rage_click", {
1221
+ element: el.tagName,
1222
+ id: el.id,
1223
+ text: el.textContent?.slice(0, 50)
1224
+ });
1225
+ clickTimes = [];
1226
+ }
1227
+ } else {
1228
+ lastClickTarget = e.target;
1229
+ clickTimes = [now];
1230
+ }
1231
+ };
1232
+ let clickTimes = [];
1233
+ let lastClickTarget = null;
1234
+ document.addEventListener("click", handleClick2, true);
1235
+ cleanups.push(() => document.removeEventListener("click", handleClick2, true));
1236
+ }
1237
+ if (config.longTask !== false && typeof PerformanceObserver !== "undefined") {
1238
+ try {
1239
+ const observer = new PerformanceObserver((list) => {
1240
+ for (const entry of list.getEntries()) {
1241
+ if (entry.duration > 5e3) {
1242
+ callbacks.onTrigger("long_task", {
1243
+ duration: Math.round(entry.duration),
1244
+ startTime: Math.round(entry.startTime)
1245
+ });
1246
+ }
1247
+ }
1248
+ });
1249
+ observer.observe({ entryTypes: ["longtask"] });
1250
+ cleanups.push(() => observer.disconnect());
1251
+ } catch {
1252
+ }
1253
+ }
1254
+ if (config.apiCascade !== false) {
1255
+ const failedRequests = [];
1256
+ const origFetch = globalThis.fetch;
1257
+ globalThis.fetch = async function(...args) {
1258
+ try {
1259
+ const res = await origFetch.apply(this, args);
1260
+ if (!res.ok && res.status >= 400) {
1261
+ const now = Date.now();
1262
+ failedRequests.push(now);
1263
+ const recentFailures = failedRequests.filter((t) => now - t < 1e4);
1264
+ if (recentFailures.length >= 3) {
1265
+ callbacks.onTrigger("api_cascade", {
1266
+ failureCount: recentFailures.length,
1267
+ windowMs: 1e4
1268
+ });
1269
+ failedRequests.length = 0;
1270
+ }
1271
+ }
1272
+ return res;
1273
+ } catch (err) {
1274
+ const now = Date.now();
1275
+ failedRequests.push(now);
1276
+ const recentFailures = failedRequests.filter((t) => now - t < 1e4);
1277
+ if (recentFailures.length >= 3) {
1278
+ callbacks.onTrigger("api_cascade", {
1279
+ failureCount: recentFailures.length,
1280
+ windowMs: 1e4
1281
+ });
1282
+ failedRequests.length = 0;
1283
+ }
1284
+ throw err;
1285
+ }
1286
+ };
1287
+ cleanups.push(() => {
1288
+ globalThis.fetch = origFetch;
1289
+ });
1290
+ }
1291
+ if (config.errorBoundary) {
1292
+ let handleError2 = function(event) {
1293
+ callbacks.onTrigger("error_boundary", {
1294
+ message: event.message,
1295
+ filename: event.filename,
1296
+ lineno: event.lineno,
1297
+ colno: event.colno
1298
+ });
1299
+ }, handleUnhandledRejection2 = function(event) {
1300
+ callbacks.onTrigger("error_boundary", {
1301
+ message: event.reason instanceof Error ? event.reason.message : String(event.reason),
1302
+ type: "unhandled_rejection"
1303
+ });
1304
+ };
1305
+ window.addEventListener("error", handleError2);
1306
+ window.addEventListener("unhandledrejection", handleUnhandledRejection2);
1307
+ cleanups.push(() => {
1308
+ window.removeEventListener("error", handleError2);
1309
+ window.removeEventListener("unhandledrejection", handleUnhandledRejection2);
1310
+ });
1311
+ }
1312
+ return {
1313
+ destroy() {
1314
+ cleanups.forEach((fn) => fn());
1315
+ }
1316
+ };
1317
+ }
1318
+
1319
+ // src/proactive-manager.ts
1320
+ var STORAGE_KEY_LAST_DISMISS = "mushi:lastDismiss";
1321
+ var STORAGE_KEY_CONSEC_DISMISS = "mushi:consecDismiss";
1322
+ function readStorage(key) {
1323
+ try {
1324
+ return localStorage.getItem(key);
1325
+ } catch {
1326
+ return null;
1327
+ }
1328
+ }
1329
+ function writeStorage(key, value) {
1330
+ try {
1331
+ localStorage.setItem(key, value);
1332
+ } catch {
1333
+ }
1334
+ }
1335
+ function createProactiveManager(config = {}) {
1336
+ const maxPerSession = config.maxProactivePerSession ?? 2;
1337
+ const cooldownHours = config.dismissCooldownHours ?? 24;
1338
+ const suppressThreshold = config.suppressAfterDismissals ?? 3;
1339
+ let sessionPromptCount = 0;
1340
+ const sessionTriggerTypes = /* @__PURE__ */ new Set();
1341
+ function shouldShow(triggerType) {
1342
+ const consecDismissals = parseInt(readStorage(STORAGE_KEY_CONSEC_DISMISS) ?? "0", 10);
1343
+ if (consecDismissals >= suppressThreshold) return false;
1344
+ const lastDismiss = readStorage(STORAGE_KEY_LAST_DISMISS);
1345
+ if (lastDismiss) {
1346
+ const elapsed = Date.now() - parseInt(lastDismiss, 10);
1347
+ if (elapsed < cooldownHours * 60 * 60 * 1e3) return false;
1348
+ }
1349
+ if (sessionPromptCount >= maxPerSession) return false;
1350
+ if (sessionTriggerTypes.has(triggerType)) return false;
1351
+ sessionTriggerTypes.add(triggerType);
1352
+ sessionPromptCount++;
1353
+ return true;
1354
+ }
1355
+ function recordDismissal() {
1356
+ writeStorage(STORAGE_KEY_LAST_DISMISS, String(Date.now()));
1357
+ const current = parseInt(readStorage(STORAGE_KEY_CONSEC_DISMISS) ?? "0", 10);
1358
+ writeStorage(STORAGE_KEY_CONSEC_DISMISS, String(current + 1));
1359
+ }
1360
+ function recordSubmission() {
1361
+ writeStorage(STORAGE_KEY_CONSEC_DISMISS, "0");
1362
+ }
1363
+ function reset() {
1364
+ sessionPromptCount = 0;
1365
+ sessionTriggerTypes.clear();
1366
+ }
1367
+ return { shouldShow, recordDismissal, recordSubmission, reset };
1368
+ }
1369
+
1202
1370
  // src/mushi.ts
1203
1371
  var instance = null;
1204
1372
  var Mushi = class {
@@ -1234,7 +1402,7 @@ function createInstance(config) {
1234
1402
  const apiClient = core.createApiClient({
1235
1403
  projectId: config.projectId,
1236
1404
  apiKey: config.apiKey,
1237
- apiEndpoint: config.apiEndpoint ?? "https://api.mushimushi.dev"
1405
+ ...config.apiEndpoint ? { apiEndpoint: config.apiEndpoint } : {}
1238
1406
  });
1239
1407
  const preFilter = core.createPreFilter(config.preFilter);
1240
1408
  const offlineQueue = core.createOfflineQueue(config.offline);
@@ -1253,9 +1421,11 @@ function createInstance(config) {
1253
1421
  const customMetadata = {};
1254
1422
  let pendingScreenshot = null;
1255
1423
  let pendingElement = null;
1424
+ let pendingProactiveTrigger = null;
1256
1425
  const widget = new MushiWidget(config.widget, {
1257
1426
  onSubmit: async ({ category, description, intent }) => {
1258
1427
  log.info("Report submitted", { category, intent });
1428
+ proactiveManager?.recordSubmission();
1259
1429
  await submitReport(category, description, intent);
1260
1430
  },
1261
1431
  onOpen: () => {
@@ -1264,8 +1434,13 @@ function createInstance(config) {
1264
1434
  },
1265
1435
  onClose: () => {
1266
1436
  log.debug("Widget closed");
1437
+ if (pendingProactiveTrigger) {
1438
+ proactiveManager?.recordDismissal();
1439
+ emit("proactive:dismissed", { type: pendingProactiveTrigger });
1440
+ }
1267
1441
  pendingScreenshot = null;
1268
1442
  pendingElement = null;
1443
+ pendingProactiveTrigger = null;
1269
1444
  emit("widget:closed");
1270
1445
  },
1271
1446
  onScreenshotRequest: async () => {
@@ -1292,6 +1467,39 @@ function createInstance(config) {
1292
1467
  widget.mount();
1293
1468
  }
1294
1469
  }
1470
+ let proactiveTriggers = null;
1471
+ let proactiveManager = null;
1472
+ const proactiveCfg = config.proactive;
1473
+ const hasAnyProactive = proactiveCfg && (proactiveCfg.rageClick !== false || proactiveCfg.longTask !== false || proactiveCfg.apiCascade !== false || proactiveCfg.errorBoundary === true);
1474
+ if (hasAnyProactive && typeof document !== "undefined") {
1475
+ proactiveManager = createProactiveManager(proactiveCfg?.cooldown);
1476
+ proactiveTriggers = setupProactiveTriggers(
1477
+ {
1478
+ onTrigger: (type, context) => {
1479
+ if (!proactiveManager.shouldShow(type)) {
1480
+ log.debug("Proactive trigger suppressed by fatigue prevention", { type });
1481
+ return;
1482
+ }
1483
+ log.info("Proactive trigger fired", { type, context });
1484
+ pendingProactiveTrigger = type;
1485
+ emit("proactive:triggered", { type, context });
1486
+ widget.open();
1487
+ }
1488
+ },
1489
+ {
1490
+ rageClick: proactiveCfg?.rageClick,
1491
+ longTask: proactiveCfg?.longTask,
1492
+ apiCascade: proactiveCfg?.apiCascade,
1493
+ errorBoundary: proactiveCfg?.errorBoundary
1494
+ }
1495
+ );
1496
+ log.debug("Proactive triggers enabled", {
1497
+ rageClick: proactiveCfg?.rageClick !== false,
1498
+ longTask: proactiveCfg?.longTask !== false,
1499
+ apiCascade: proactiveCfg?.apiCascade !== false,
1500
+ errorBoundary: proactiveCfg?.errorBoundary === true
1501
+ });
1502
+ }
1295
1503
  offlineQueue.startAutoSync(apiClient);
1296
1504
  offlineQueue.flush(apiClient).then((result) => {
1297
1505
  if (result.sent > 0) log.info("Synced offline reports", { sent: result.sent });
@@ -1303,6 +1511,34 @@ function createInstance(config) {
1303
1511
  log.info("Report blocked by pre-filter", { reason: filterResult.reason });
1304
1512
  return;
1305
1513
  }
1514
+ const wasm = config.preFilter?.wasmClassifier;
1515
+ if (wasm) {
1516
+ try {
1517
+ const verdict = await wasm.classify({
1518
+ description,
1519
+ category,
1520
+ url: typeof location !== "undefined" ? location.href : void 0,
1521
+ hasScreenshot: pendingScreenshot !== null,
1522
+ hasSelectedElement: pendingElement !== null,
1523
+ hasNetworkErrors: networkCap?.getEntries()?.some((e) => e.status >= 400 || !!e.error) ?? false,
1524
+ hasConsoleErrors: consoleCap?.getEntries()?.some((e) => e.level === "error") ?? false,
1525
+ proactiveTrigger: pendingProactiveTrigger ?? void 0
1526
+ });
1527
+ if (verdict.verdict === "block") {
1528
+ log.info("Report blocked by on-device classifier", {
1529
+ modelId: verdict.modelId,
1530
+ confidence: verdict.confidence,
1531
+ reason: verdict.reason
1532
+ });
1533
+ return;
1534
+ }
1535
+ log.debug("On-device classifier verdict", { ...verdict });
1536
+ } catch (err) {
1537
+ log.warn("On-device classifier threw \u2014 falling through to server", {
1538
+ error: err instanceof Error ? err.message : String(err)
1539
+ });
1540
+ }
1541
+ }
1306
1542
  if (!rateLimiter.tryConsume()) {
1307
1543
  log.warn("Report throttled \u2014 rate limit exceeded");
1308
1544
  return;
@@ -1329,6 +1565,7 @@ function createInstance(config) {
1329
1565
  sessionId: core.getSessionId(),
1330
1566
  reporterToken: core.getReporterToken(),
1331
1567
  appVersion: config.integrations?.vercel?.analyticsId,
1568
+ proactiveTrigger: pendingProactiveTrigger ?? void 0,
1332
1569
  sentryEventId: sentryCtx?.eventId,
1333
1570
  sentryReplayId: sentryCtx?.replayId,
1334
1571
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -1365,10 +1602,11 @@ function createInstance(config) {
1365
1602
  }
1366
1603
  pendingScreenshot = null;
1367
1604
  pendingElement = null;
1605
+ pendingProactiveTrigger = null;
1368
1606
  }
1369
1607
  const sdk = {
1370
- report() {
1371
- widget.open();
1608
+ report(options) {
1609
+ widget.open(options);
1372
1610
  },
1373
1611
  on(event, handler) {
1374
1612
  if (!listeners.has(event)) listeners.set(event, /* @__PURE__ */ new Set());
@@ -1391,6 +1629,8 @@ function createInstance(config) {
1391
1629
  widget.close();
1392
1630
  },
1393
1631
  destroy() {
1632
+ proactiveTriggers?.destroy();
1633
+ proactiveManager?.reset();
1394
1634
  widget.destroy();
1395
1635
  consoleCap?.destroy();
1396
1636
  networkCap?.destroy();
@@ -1425,142 +1665,6 @@ function createNoopInstance() {
1425
1665
  };
1426
1666
  }
1427
1667
 
1428
- // src/proactive-manager.ts
1429
- var STORAGE_KEY_LAST_DISMISS = "mushi:lastDismiss";
1430
- var STORAGE_KEY_CONSEC_DISMISS = "mushi:consecDismiss";
1431
- function readStorage(key) {
1432
- try {
1433
- return localStorage.getItem(key);
1434
- } catch {
1435
- return null;
1436
- }
1437
- }
1438
- function writeStorage(key, value) {
1439
- try {
1440
- localStorage.setItem(key, value);
1441
- } catch {
1442
- }
1443
- }
1444
- function createProactiveManager(config = {}) {
1445
- const maxPerSession = config.maxProactivePerSession ?? 2;
1446
- const cooldownHours = config.dismissCooldownHours ?? 24;
1447
- const suppressThreshold = config.suppressAfterDismissals ?? 3;
1448
- let sessionPromptCount = 0;
1449
- const sessionTriggerTypes = /* @__PURE__ */ new Set();
1450
- function shouldShow(triggerType) {
1451
- const consecDismissals = parseInt(readStorage(STORAGE_KEY_CONSEC_DISMISS) ?? "0", 10);
1452
- if (consecDismissals >= suppressThreshold) return false;
1453
- const lastDismiss = readStorage(STORAGE_KEY_LAST_DISMISS);
1454
- if (lastDismiss) {
1455
- const elapsed = Date.now() - parseInt(lastDismiss, 10);
1456
- if (elapsed < cooldownHours * 60 * 60 * 1e3) return false;
1457
- }
1458
- if (sessionPromptCount >= maxPerSession) return false;
1459
- if (sessionTriggerTypes.has(triggerType)) return false;
1460
- sessionTriggerTypes.add(triggerType);
1461
- sessionPromptCount++;
1462
- return true;
1463
- }
1464
- function recordDismissal() {
1465
- writeStorage(STORAGE_KEY_LAST_DISMISS, String(Date.now()));
1466
- const current = parseInt(readStorage(STORAGE_KEY_CONSEC_DISMISS) ?? "0", 10);
1467
- writeStorage(STORAGE_KEY_CONSEC_DISMISS, String(current + 1));
1468
- }
1469
- function recordSubmission() {
1470
- writeStorage(STORAGE_KEY_CONSEC_DISMISS, "0");
1471
- }
1472
- function reset() {
1473
- sessionPromptCount = 0;
1474
- sessionTriggerTypes.clear();
1475
- }
1476
- return { shouldShow, recordDismissal, recordSubmission, reset };
1477
- }
1478
-
1479
- // src/proactive-triggers.ts
1480
- function setupProactiveTriggers(callbacks) {
1481
- const cleanups = [];
1482
- let clickTimes = [];
1483
- let lastClickTarget = null;
1484
- function handleClick(e) {
1485
- const now = Date.now();
1486
- if (e.target === lastClickTarget) {
1487
- clickTimes.push(now);
1488
- clickTimes = clickTimes.filter((t) => now - t < 500);
1489
- if (clickTimes.length >= 3) {
1490
- const el = e.target;
1491
- callbacks.onTrigger("rage_click", {
1492
- element: el.tagName,
1493
- id: el.id,
1494
- text: el.textContent?.slice(0, 50)
1495
- });
1496
- clickTimes = [];
1497
- }
1498
- } else {
1499
- lastClickTarget = e.target;
1500
- clickTimes = [now];
1501
- }
1502
- }
1503
- document.addEventListener("click", handleClick, true);
1504
- cleanups.push(() => document.removeEventListener("click", handleClick, true));
1505
- if (typeof PerformanceObserver !== "undefined") {
1506
- try {
1507
- const observer = new PerformanceObserver((list) => {
1508
- for (const entry of list.getEntries()) {
1509
- if (entry.duration > 5e3) {
1510
- callbacks.onTrigger("long_task", {
1511
- duration: Math.round(entry.duration),
1512
- startTime: Math.round(entry.startTime)
1513
- });
1514
- }
1515
- }
1516
- });
1517
- observer.observe({ entryTypes: ["longtask"] });
1518
- cleanups.push(() => observer.disconnect());
1519
- } catch {
1520
- }
1521
- }
1522
- const failedRequests = [];
1523
- const origFetch = globalThis.fetch;
1524
- globalThis.fetch = async function(...args) {
1525
- try {
1526
- const res = await origFetch.apply(this, args);
1527
- if (!res.ok && res.status >= 400) {
1528
- const now = Date.now();
1529
- failedRequests.push(now);
1530
- const recentFailures = failedRequests.filter((t) => now - t < 1e4);
1531
- if (recentFailures.length >= 3) {
1532
- callbacks.onTrigger("api_cascade", {
1533
- failureCount: recentFailures.length,
1534
- windowMs: 1e4
1535
- });
1536
- failedRequests.length = 0;
1537
- }
1538
- }
1539
- return res;
1540
- } catch (err) {
1541
- const now = Date.now();
1542
- failedRequests.push(now);
1543
- const recentFailures = failedRequests.filter((t) => now - t < 1e4);
1544
- if (recentFailures.length >= 3) {
1545
- callbacks.onTrigger("api_cascade", {
1546
- failureCount: recentFailures.length,
1547
- windowMs: 1e4
1548
- });
1549
- failedRequests.length = 0;
1550
- }
1551
- throw err;
1552
- }
1553
- };
1554
- cleanups.push(() => {
1555
- globalThis.fetch = origFetch;
1556
- });
1557
- return {
1558
- destroy() {
1559
- cleanups.forEach((fn) => fn());
1560
- }
1561
- };
1562
- }
1563
-
1564
1668
  exports.Mushi = Mushi;
1565
1669
  exports.MushiWidget = MushiWidget;
1566
1670
  exports.createConsoleCapture = createConsoleCapture;