@mushi-mushi/web 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTRIBUTING.md CHANGED
@@ -33,6 +33,10 @@ pnpm lint # ESLint
33
33
  pnpm format # Prettier
34
34
  ```
35
35
 
36
+ Ad-hoc screenshots captured during UI reviews can live temporarily at the repo
37
+ root, but root-level `*.png` files are intentionally ignored. Canonical
38
+ screenshots that should be versioned belong under `docs/screenshots/`.
39
+
36
40
  ### Working on a single package
37
41
 
38
42
  ```bash
package/README.md CHANGED
@@ -17,6 +17,8 @@ Browser SDK for Mushi Mushi — embeddable bug reporting widget with Shadow DOM
17
17
  - On-device pre-filter (blocks spam before server submission)
18
18
  - Client-side rate limiting (token bucket self-throttle)
19
19
  - Light/dark theme with auto-detection (`prefers-color-scheme`)
20
+ - **Trigger modes** (0.6+) — `auto` / `edge-tab` / `attach` (bring-your-own-button) / `manual` / `hidden`, plus `smartHide`, `hideOnSelector`, `hideOnRoutes`, configurable `inset` and `respectSafeArea`
21
+ - **Runtime trigger APIs** — `Mushi.show()`, `Mushi.hide()`, `Mushi.attachTo(selector)`, `Mushi.setTrigger(mode)`, `Mushi.openWith(category)`
20
22
  - **Proactive triggers** — rage click, long task, API cascade failure detection
21
23
  - **Report fatigue prevention** — session limits, cooldowns, permanent suppression
22
24
  - Keyboard-first: `Esc` to close, `⌘/Ctrl + Enter` to submit, focus-trapped panel
@@ -84,6 +86,45 @@ Mushi.init({
84
86
  });
85
87
  ```
86
88
 
89
+ ### Bring your own launcher (`trigger: 'attach'`)
90
+
91
+ For mature production apps, prefer hosting the launcher inside your own help
92
+ menu, settings page, or beta banner. Mushi will not inject any UI of its own.
93
+
94
+ ```typescript
95
+ const mushi = Mushi.init({
96
+ projectId: 'proj_xxx',
97
+ apiKey: 'mushi_xxx',
98
+ widget: {
99
+ trigger: 'attach',
100
+ attachToSelector: '[data-mushi-feedback]',
101
+ },
102
+ });
103
+
104
+ mushi.attachTo('#support-menu-feedback');
105
+ mushi.hide();
106
+ ```
107
+
108
+ ### Smart-hide (`trigger: 'auto'` with viewport awareness)
109
+
110
+ ```typescript
111
+ Mushi.init({
112
+ projectId: 'proj_xxx',
113
+ apiKey: 'mushi_xxx',
114
+ widget: {
115
+ trigger: 'auto',
116
+ smartHide: { onMobile: 'edge-tab', onScroll: 'shrink', onIdleMs: 900 },
117
+ inset: { bottom: 96, right: 20 },
118
+ hideOnSelector: '[data-fullscreen-player]',
119
+ hideOnRoutes: ['/checkout/payment'],
120
+ respectSafeArea: true,
121
+ },
122
+ });
123
+ ```
124
+
125
+ See [Trigger modes](https://docs.mushimushi.dev/concepts/trigger-modes) for the
126
+ full posture matrix (`auto` / `edge-tab` / `attach` / `manual` / `hidden`).
127
+
87
128
  ### With Proactive Triggers
88
129
 
89
130
  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:
package/SECURITY.md CHANGED
@@ -48,3 +48,77 @@ We will acknowledge receipt within 48 hours and aim to release a patch within 7
48
48
  - **Rotate API keys** regularly via the admin console
49
49
  - **Enable SSO** for team projects (Enterprise tier)
50
50
  - **Review audit logs** periodically for suspicious activity
51
+
52
+ ## Supply-chain hardening (how this package is protected)
53
+
54
+ Mushi Mushi is built and published with the controls below. Consumers can
55
+ verify each control independently — the goal is to make tampering both
56
+ difficult and detectable.
57
+
58
+ ### Publish-time controls
59
+
60
+ | Control | What it does | How to verify |
61
+ |---|---|---|
62
+ | **npm Trusted Publisher (OIDC)** | Every release is published from `.github/workflows/release.yml` on `master` using a short-lived OIDC token. Long-lived `NPM_TOKEN` is not used for publishing. | `npm view @mushi-mushi/<pkg> --json` shows `"trustedPublisher"` populated for recent versions. |
63
+ | **npm provenance attestations** | Every published tarball ships a [Sigstore provenance attestation](https://docs.npmjs.com/generating-provenance-statements) cryptographically linking the tarball to the exact GitHub Actions run that built it. | `npm audit signatures` (run inside any project that depends on `@mushi-mushi/*`) reports `verified registry signatures` and `verified attestations`. The npm web UI shows a "Built and signed on GitHub Actions" badge on each version. |
64
+ | **Pre-publish workspace-protocol guard** | Aborts the publish if `workspace:*` ranges leaked into the tarball (the bug class behind the v0.1.0 incident). | `scripts/check-workspace-protocol.mjs` runs before `changeset publish` in `pnpm release`. |
65
+ | **Post-publish tarball verification** | Re-downloads each just-published tarball and asserts it doesn't contain `workspace:*`. | See the "Verify published tarballs do not contain workspace:*" step in `release.yml`. |
66
+ | **Post-publish `npm audit signatures`** | Re-installs each published version and validates registry signatures + provenance against npm's transparency log. | See the "Audit signatures of installed dependencies" step in `release.yml`. |
67
+
68
+ ### Build-time controls
69
+
70
+ | Control | What it does |
71
+ |---|---|
72
+ | **All third-party GitHub Actions pinned to commit SHAs** | Every `uses:` in every workflow under `.github/workflows/` is pinned to a 40-character commit SHA with a version comment. Floating tags (`@v4`, `@main`) are mutable and were the entry point for the [tj-actions/changed-files compromise (CVE-2025-30066)](https://github.com/step-security/harden-runner#detected-attacks). |
73
+ | **Harden-Runner egress audit on every job** | [step-security/harden-runner](https://github.com/step-security/harden-runner) records every outbound network call, file write, and process spawn on every CI runner. Detects exfiltration attempts in real time — caught the tj-actions, NX, Shai-Hulud, and Axios attacks for other projects. |
74
+ | **OpenSSF Scorecard** | Weekly + on-push score of the repo's security posture (Pinned-Dependencies, Token-Permissions, Branch-Protection, Code-Review, Dangerous-Workflow, Maintained, SAST, Security-Policy, Signed-Releases, Vulnerabilities). Public results at [scorecard.dev](https://scorecard.dev/viewer/?uri=github.com/kensaurus/mushi-mushi). |
75
+ | **Server-side secret scan (Gitleaks)** | Every PR and every push to `master` runs Gitleaks across the diff / full tree. Belt-and-suspenders to the local pre-commit hook (`scripts/check-no-secrets.mjs`) which can be bypassed with `--no-verify`. |
76
+ | **Local pre-commit secret scanner** | `scripts/check-no-secrets.mjs` runs as a git hook installed by `pnpm install`, blocking commits that look like AWS / Stripe / GitHub / Anthropic / OpenAI / Slack / Supabase keys. |
77
+ | **CodeQL `security-extended`** | Semantic analysis of every TypeScript / JavaScript change finds injection sinks, taint flows, prototype pollution, etc. Runs on every PR, push, and weekly cron. |
78
+ | **Dependency review on PRs** | `actions/dependency-review-action` blocks the PR if it adds or upgrades a dep with a high-severity advisory. |
79
+ | **`pnpm audit --prod --audit-level=high`** | Weekly cron + every push to `master` fails on any high/critical advisory in production deps. |
80
+
81
+ ### Install-time controls (protect the project's own dependency graph)
82
+
83
+ | Control | What it does |
84
+ |---|---|
85
+ | **`min-release-age` (npm) / `minimumReleaseAge` (pnpm)** | Refuses to resolve any dep version published less than 7 days ago. The Axios 1.14.1 / 0.30.4 compromise (Mar 2026) was detected and removed within ~5 hours; Shai-Hulud (Sep 2025) within <12 hours — a 7-day cooldown blocks every publicly-disclosed 2025–2026 npm supply-chain attack outright. |
86
+ | **`strictDepBuilds: true`** | Fails the install if any transitive dep tries to run a `postinstall` hook the workspace hasn't pre-approved (`onlyBuiltDependencies` allow-list). |
87
+ | **`blockExoticSubdeps: true`** | Refuses to resolve transitive deps from git URLs, tarball URLs, or filesystem paths — anything that didn't go through the npm registry's signing pipeline. |
88
+ | **Dependabot with cooldown** | Routine dep upgrades wait 7 days; security advisories bypass the cooldown automatically. |
89
+ | **`pnpm audit signatures`-style verification** | The release pipeline re-runs `npm audit signatures` against each published version after the publish, with `--audit-level=high`. |
90
+
91
+ ### Verifying a Mushi Mushi tarball before installing
92
+
93
+ ```bash
94
+ # 1. Check provenance attestation matches the public GitHub Actions run
95
+ npm view @mushi-mushi/core --json | jq '.signatures, .dist'
96
+
97
+ # 2. Inside your own project after install
98
+ npm audit signatures
99
+
100
+ # Expected: every @mushi-mushi/* package reports
101
+ # "verified registry signature"
102
+ # "verified attestation"
103
+ ```
104
+
105
+ If `npm audit signatures` reports any `@mushi-mushi/*` package as unsigned
106
+ or with an invalid attestation, **stop the install and email
107
+ kensaurus@gmail.com immediately** — that's the symptom of either a
108
+ registry compromise or a tampered tarball, and we want to know within
109
+ hours, not days.
110
+
111
+ ### What this hardening does NOT cover
112
+
113
+ - **Self-hosted deployments.** Once the package is on your machine, the
114
+ security of your `node_modules`, build pipeline, and runtime is your
115
+ responsibility. The hardening above protects the path from source to
116
+ registry; it cannot protect a tarball after it has been downloaded.
117
+ - **Compromise of `kensaurus@gmail.com`.** A trusted-publisher rule still
118
+ lets the legitimate maintainer publish from any branch they push. If
119
+ you find yourself with admin access to this repo, treat
120
+ `.github/workflows/release.yml` as a tier-0 secret.
121
+ - **First-party bugs.** Provenance proves *who* built the tarball and
122
+ *when*; it does not prove the code is bug-free. CodeQL + tests cover
123
+ that surface, but no automation catches everything — please continue
124
+ to report issues to the address above.
package/dist/index.cjs CHANGED
@@ -245,11 +245,6 @@ function getWidgetStyles(theme) {
245
245
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
246
246
  button { font-family: inherit; }
247
247
 
248
- /* \u2500\u2500 Trigger \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
249
- A small "stamp card" \u2014 soft rounded square (4px radius), paper
250
- background, vermillion bottom edge that reads as the inked face
251
- of a real \u5370\u9451. A pulsing dot in the top-right hints there's a
252
- channel here without needing a notification badge. */
253
248
  .mushi-trigger {
254
249
  position: fixed;
255
250
  width: 52px;
@@ -303,10 +298,53 @@ function getWidgetStyles(theme) {
303
298
  outline: 2px solid ${vermillion};
304
299
  outline-offset: 3px;
305
300
  }
306
- .mushi-trigger.bottom-right { bottom: 24px; right: 24px; }
307
- .mushi-trigger.bottom-left { bottom: 24px; left: 24px; }
308
- .mushi-trigger.top-right { top: 24px; right: 24px; }
309
- .mushi-trigger.top-left { top: 24px; left: 24px; }
301
+ .mushi-trigger.bottom-right {
302
+ bottom: var(--mushi-bottom, calc(24px + env(safe-area-inset-bottom, 0px)));
303
+ right: var(--mushi-right, calc(24px + env(safe-area-inset-right, 0px)));
304
+ }
305
+ .mushi-trigger.bottom-left {
306
+ bottom: var(--mushi-bottom, calc(24px + env(safe-area-inset-bottom, 0px)));
307
+ left: var(--mushi-left, calc(24px + env(safe-area-inset-left, 0px)));
308
+ }
309
+ .mushi-trigger.top-right {
310
+ top: var(--mushi-top, calc(24px + env(safe-area-inset-top, 0px)));
311
+ right: var(--mushi-right, calc(24px + env(safe-area-inset-right, 0px)));
312
+ }
313
+ .mushi-trigger.top-left {
314
+ top: var(--mushi-top, calc(24px + env(safe-area-inset-top, 0px)));
315
+ left: var(--mushi-left, calc(24px + env(safe-area-inset-left, 0px)));
316
+ }
317
+ .mushi-trigger.edge-tab {
318
+ width: 32px;
319
+ height: 88px;
320
+ border-radius: 4px 0 0 4px;
321
+ writing-mode: vertical-rl;
322
+ text-orientation: upright;
323
+ font-size: 16px;
324
+ box-shadow:
325
+ 0 1px 0 ${rule},
326
+ 0 10px 24px -14px rgba(14,13,11,0.45),
327
+ inset -3px 0 0 ${vermillion};
328
+ }
329
+ .mushi-trigger.edge-tab.bottom-right,
330
+ .mushi-trigger.edge-tab.top-right {
331
+ right: var(--mushi-right, 0);
332
+ }
333
+ .mushi-trigger.edge-tab.bottom-left,
334
+ .mushi-trigger.edge-tab.top-left {
335
+ left: var(--mushi-left, 0);
336
+ border-radius: 0 4px 4px 0;
337
+ box-shadow:
338
+ 0 1px 0 ${rule},
339
+ 0 10px 24px -14px rgba(14,13,11,0.45),
340
+ inset 3px 0 0 ${vermillion};
341
+ }
342
+ .mushi-trigger.shrunk {
343
+ width: 36px;
344
+ height: 36px;
345
+ opacity: 0.82;
346
+ transform: scale(0.92);
347
+ }
310
348
 
311
349
  @keyframes mushi-pulse {
312
350
  0% { box-shadow: 0 0 0 0 ${vermillion}; opacity: 1; }
@@ -314,12 +352,6 @@ function getWidgetStyles(theme) {
314
352
  100% { box-shadow: 0 0 0 0 rgba(224,60,44,0); opacity: 1; }
315
353
  }
316
354
 
317
- /* \u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
318
- Paper-card. Sharper corners (6px) than typical SaaS modals
319
- (which default to 12-16px and read as plastic). Two-layer shadow:
320
- one hairline that sells the paper edge, one diffuse that lifts
321
- the panel off the underlying app. No backdrop-filter \u2014 we want
322
- the widget to feel like it sits ON the page, not blur INTO it. */
323
355
  .mushi-panel {
324
356
  position: fixed;
325
357
  width: 384px;
@@ -339,10 +371,26 @@ function getWidgetStyles(theme) {
339
371
  }
340
372
  .mushi-panel.open { animation: mushi-stamp-in 320ms ${easeStamp} both; }
341
373
  .mushi-panel.closed { display: none; }
342
- .mushi-panel.bottom-right { bottom: 88px; right: 24px; --mushi-origin: bottom right; }
343
- .mushi-panel.bottom-left { bottom: 88px; left: 24px; --mushi-origin: bottom left; }
344
- .mushi-panel.top-right { top: 88px; right: 24px; --mushi-origin: top right; }
345
- .mushi-panel.top-left { top: 88px; left: 24px; --mushi-origin: top left; }
374
+ .mushi-panel.bottom-right {
375
+ bottom: var(--mushi-panel-bottom, calc(var(--mushi-bottom, 24px) + 64px));
376
+ right: var(--mushi-right, calc(24px + env(safe-area-inset-right, 0px)));
377
+ --mushi-origin: bottom right;
378
+ }
379
+ .mushi-panel.bottom-left {
380
+ bottom: var(--mushi-panel-bottom, calc(var(--mushi-bottom, 24px) + 64px));
381
+ left: var(--mushi-left, calc(24px + env(safe-area-inset-left, 0px)));
382
+ --mushi-origin: bottom left;
383
+ }
384
+ .mushi-panel.top-right {
385
+ top: var(--mushi-panel-top, calc(var(--mushi-top, 24px) + 64px));
386
+ right: var(--mushi-right, calc(24px + env(safe-area-inset-right, 0px)));
387
+ --mushi-origin: top right;
388
+ }
389
+ .mushi-panel.top-left {
390
+ top: var(--mushi-panel-top, calc(var(--mushi-top, 24px) + 64px));
391
+ left: var(--mushi-left, calc(24px + env(safe-area-inset-left, 0px)));
392
+ --mushi-origin: top left;
393
+ }
346
394
 
347
395
  @keyframes mushi-stamp-in {
348
396
  0% { opacity: 0; transform: scale(0.94) translateY(6px); }
@@ -350,10 +398,6 @@ function getWidgetStyles(theme) {
350
398
  100% { opacity: 1; transform: scale(1) translateY(0); }
351
399
  }
352
400
 
353
- /* \u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
354
- Editorial masthead: small mono eyebrow ("MUSHI / REPORT") on top,
355
- serif display headline below, mono step counter on the far right.
356
- A single hairline separates header from body \u2014 no card stacking. */
357
401
  .mushi-header {
358
402
  padding: 18px 20px 14px;
359
403
  border-bottom: 1px solid ${rule};
@@ -450,10 +494,6 @@ function getWidgetStyles(theme) {
450
494
  .mushi-body::-webkit-scrollbar { width: 6px; }
451
495
  .mushi-body::-webkit-scrollbar-thumb { background: ${inkFaint}; border-radius: 3px; }
452
496
 
453
- /* \u2500\u2500 Step 1: Categories as a contents-page list \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
454
- No boxes. Hairline rules between rows. Hovering a row pulls a
455
- vermillion arrow in from the right and tints the row label \u2014
456
- reads like flipping through an index card. */
457
497
  .mushi-option-btn {
458
498
  display: grid;
459
499
  grid-template-columns: auto 1fr auto;
@@ -506,11 +546,6 @@ function getWidgetStyles(theme) {
506
546
  transition: opacity 220ms ${easeStamp}, transform 220ms ${easeStamp}, color 220ms ${easeStamp};
507
547
  }
508
548
 
509
- /* \u2500\u2500 Step 2: Selected-category breadcrumb + intent text-buttons \u2500
510
- Breadcrumb is a thin chip with the kanji-stamp aesthetic carried
511
- over (vermillion left rule). Intents are inline TEXT buttons
512
- with vermillion underlines on hover \u2014 not pill-shaped chips,
513
- which is the SaaS default and not what we are. */
514
549
  .mushi-selected-category {
515
550
  display: inline-flex;
516
551
  align-items: center;
@@ -566,10 +601,6 @@ function getWidgetStyles(theme) {
566
601
  box-shadow: inset 2px 0 0 ${vermillion};
567
602
  }
568
603
 
569
- /* \u2500\u2500 Step 3: Borderless textarea + minimal attach pills \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
570
- The textarea has no box around it \u2014 just a hairline underline
571
- that turns vermillion on focus. Encourages writing rather than
572
- form-filling. */
573
604
  .mushi-textarea {
574
605
  width: 100%;
575
606
  min-height: 96px;
@@ -627,10 +658,6 @@ function getWidgetStyles(theme) {
627
658
  outline-offset: 2px;
628
659
  }
629
660
 
630
- /* \u2500\u2500 Footer + submit (vermillion stamp) \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
631
- Submit button is the heaviest visual moment in the widget \u2014
632
- vermillion fill, mono-caps label, send arrow. Holds an ink-
633
- bloom pseudo-element that animates outward when pressed. */
634
661
  .mushi-footer {
635
662
  padding: 14px 22px 16px;
636
663
  border-top: 1px solid ${rule};
@@ -695,10 +722,6 @@ function getWidgetStyles(theme) {
695
722
  }
696
723
  .mushi-submit:hover .mushi-submit-arrow { transform: translateX(3px); }
697
724
 
698
- /* \u2500\u2500 Step indicator (numeral ledger) \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
699
- Replaces the generic three-dots with a typographic series:
700
- "01 \u2014 02 \u2014 03". The active step uses serif numerals, the
701
- others use mono so the active one literally reads heavier. */
702
725
  .mushi-step-indicator {
703
726
  display: flex;
704
727
  align-items: center;
@@ -726,10 +749,6 @@ function getWidgetStyles(theme) {
726
749
  }
727
750
  .mushi-step-sep { width: 14px; height: 1px; background: ${rule}; }
728
751
 
729
- /* \u2500\u2500 Success: \u6731\u5370 stamp animation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
730
- The success state is the signature moment. A vermillion ring
731
- scribes itself, then a "RECEIVED" mono-caps label fades in at
732
- the centre, evoking a hanko being pressed onto the form. */
733
752
  .mushi-success {
734
753
  text-align: center;
735
754
  padding: 28px 16px 20px;
@@ -793,9 +812,6 @@ function getWidgetStyles(theme) {
793
812
  100% { opacity: 1; transform: rotate(-6deg) scale(1); }
794
813
  }
795
814
 
796
- /* \u2500\u2500 Error \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
797
- Inline editorial note rather than a red box. Vermillion left
798
- rule keeps the same accent language. */
799
815
  .mushi-error {
800
816
  margin-top: 10px;
801
817
  padding: 8px 0 8px 10px;
@@ -806,9 +822,6 @@ function getWidgetStyles(theme) {
806
822
  letter-spacing: 0.02em;
807
823
  }
808
824
 
809
- /* \u2500\u2500 Reduced motion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
810
- Honour the OS preference: kill every transition + animation
811
- except the focus underline (which is critical feedback). */
812
825
  @media (prefers-reduced-motion: reduce) {
813
826
  *,
814
827
  *::before,
@@ -856,6 +869,12 @@ var MushiWidget = class {
856
869
  screenshotAttached = false;
857
870
  elementSelected = false;
858
871
  submitting = false;
872
+ triggerVisible = true;
873
+ triggerShrunk = false;
874
+ triggerHiddenByScroll = false;
875
+ attachedLaunchers = [];
876
+ smartHideCleanup = null;
877
+ smartHideTimer = null;
859
878
  /** Captured at the moment of submit so the success ledger metadata
860
879
  * ("REPORT · 14:23:07 JST") doesn't drift while the success step
861
880
  * is on screen. */
@@ -881,7 +900,16 @@ var MushiWidget = class {
881
900
  expandedTitle: config.expandedTitle ?? "",
882
901
  mode: config.mode ?? "conversational",
883
902
  locale: config.locale ?? "auto",
884
- zIndex: config.zIndex ?? 99999
903
+ zIndex: config.zIndex ?? 99999,
904
+ trigger: config.trigger ?? "auto",
905
+ attachToSelector: config.attachToSelector ?? "",
906
+ inset: config.inset ?? {},
907
+ respectSafeArea: config.respectSafeArea ?? true,
908
+ hideOnSelector: config.hideOnSelector ?? "",
909
+ hideOnRoutes: config.hideOnRoutes ?? [],
910
+ environments: config.environments ?? {},
911
+ smartHide: config.smartHide ?? false,
912
+ draggable: config.draggable ?? false
885
913
  };
886
914
  this.callbacks = callbacks;
887
915
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
@@ -891,6 +919,8 @@ var MushiWidget = class {
891
919
  }
892
920
  mount() {
893
921
  document.body.appendChild(this.host);
922
+ this.syncAttachedLaunchers();
923
+ this.syncSmartHide();
894
924
  this.render();
895
925
  }
896
926
  updateConfig(config = {}) {
@@ -902,9 +932,20 @@ var MushiWidget = class {
902
932
  ...config.expandedTitle !== void 0 ? { expandedTitle: config.expandedTitle } : {},
903
933
  ...config.mode ? { mode: config.mode } : {},
904
934
  ...config.locale ? { locale: config.locale } : {},
905
- ...config.zIndex !== void 0 ? { zIndex: config.zIndex } : {}
935
+ ...config.zIndex !== void 0 ? { zIndex: config.zIndex } : {},
936
+ ...config.trigger ? { trigger: config.trigger } : {},
937
+ ...config.attachToSelector !== void 0 ? { attachToSelector: config.attachToSelector } : {},
938
+ ...config.inset !== void 0 ? { inset: config.inset } : {},
939
+ ...config.respectSafeArea !== void 0 ? { respectSafeArea: config.respectSafeArea } : {},
940
+ ...config.hideOnSelector !== void 0 ? { hideOnSelector: config.hideOnSelector } : {},
941
+ ...config.hideOnRoutes !== void 0 ? { hideOnRoutes: config.hideOnRoutes } : {},
942
+ ...config.environments !== void 0 ? { environments: config.environments } : {},
943
+ ...config.smartHide !== void 0 ? { smartHide: config.smartHide } : {},
944
+ ...config.draggable !== void 0 ? { draggable: config.draggable } : {}
906
945
  };
907
946
  this.locale = getLocale(this.config.locale === "auto" ? void 0 : this.config.locale);
947
+ this.syncAttachedLaunchers();
948
+ this.syncSmartHide();
908
949
  this.render();
909
950
  }
910
951
  open(options) {
@@ -935,6 +976,30 @@ var MushiWidget = class {
935
976
  getIsOpen() {
936
977
  return this.isOpen;
937
978
  }
979
+ showTrigger() {
980
+ this.triggerVisible = true;
981
+ this.render();
982
+ }
983
+ hideTrigger() {
984
+ this.triggerVisible = false;
985
+ this.render();
986
+ }
987
+ setTrigger(trigger) {
988
+ this.updateConfig({ trigger });
989
+ }
990
+ attachTo(selectorOrElement, options = {}) {
991
+ const elements = typeof selectorOrElement === "string" ? Array.from(document.querySelectorAll(selectorOrElement)) : [selectorOrElement];
992
+ const cleanups = elements.map((el) => {
993
+ const onClick = (event) => {
994
+ event.preventDefault();
995
+ this.updateConfig(options);
996
+ this.open();
997
+ };
998
+ el.addEventListener("click", onClick);
999
+ return () => el.removeEventListener("click", onClick);
1000
+ });
1001
+ return () => cleanups.forEach((cleanup) => cleanup());
1002
+ }
938
1003
  setScreenshotAttached(attached) {
939
1004
  this.screenshotAttached = attached;
940
1005
  if (this.isOpen) this.render();
@@ -952,8 +1017,83 @@ var MushiWidget = class {
952
1017
  clearTimeout(this.autoCloseTimer);
953
1018
  this.autoCloseTimer = null;
954
1019
  }
1020
+ if (this.smartHideTimer !== null) {
1021
+ clearTimeout(this.smartHideTimer);
1022
+ this.smartHideTimer = null;
1023
+ }
1024
+ this.smartHideCleanup?.();
1025
+ this.smartHideCleanup = null;
1026
+ this.attachedLaunchers.forEach((cleanup) => cleanup());
1027
+ this.attachedLaunchers = [];
955
1028
  this.host.remove();
956
1029
  }
1030
+ syncAttachedLaunchers() {
1031
+ this.attachedLaunchers.forEach((cleanup) => cleanup());
1032
+ this.attachedLaunchers = [];
1033
+ if (this.config.trigger !== "attach" || !this.config.attachToSelector) return;
1034
+ if (typeof document === "undefined") return;
1035
+ this.attachedLaunchers.push(this.attachTo(this.config.attachToSelector));
1036
+ }
1037
+ syncSmartHide() {
1038
+ this.smartHideCleanup?.();
1039
+ this.smartHideCleanup = null;
1040
+ this.triggerShrunk = false;
1041
+ this.triggerHiddenByScroll = false;
1042
+ if (!this.config.smartHide || typeof window === "undefined") return;
1043
+ const smart = this.config.smartHide === true ? { onScroll: "shrink", onIdleMs: 900 } : this.config.smartHide;
1044
+ if (!smart.onScroll) return;
1045
+ const onScroll = () => {
1046
+ if (smart.onScroll === "hide") {
1047
+ this.triggerHiddenByScroll = true;
1048
+ } else {
1049
+ this.triggerShrunk = true;
1050
+ }
1051
+ this.render();
1052
+ if (this.smartHideTimer !== null) clearTimeout(this.smartHideTimer);
1053
+ this.smartHideTimer = setTimeout(() => {
1054
+ this.triggerHiddenByScroll = false;
1055
+ this.triggerShrunk = false;
1056
+ this.render();
1057
+ }, smart.onIdleMs ?? 900);
1058
+ };
1059
+ window.addEventListener("scroll", onScroll, { passive: true });
1060
+ this.smartHideCleanup = () => window.removeEventListener("scroll", onScroll);
1061
+ }
1062
+ shouldRenderTrigger() {
1063
+ if (!this.triggerVisible) return false;
1064
+ if (this.triggerHiddenByScroll) return false;
1065
+ if (this.config.trigger === "manual" || this.config.trigger === "hidden" || this.config.trigger === "attach") {
1066
+ return false;
1067
+ }
1068
+ if (this.isMobileSmartHidden()) return false;
1069
+ if (this.isRouteHidden()) return false;
1070
+ if (this.config.hideOnSelector && document.querySelector(this.config.hideOnSelector)) return false;
1071
+ const action = this.config.environments[this.detectEnvironment()];
1072
+ return action !== "never" && action !== "manual";
1073
+ }
1074
+ effectiveTrigger() {
1075
+ if (!this.config.smartHide || typeof window === "undefined") return this.config.trigger;
1076
+ const smart = this.config.smartHide === true ? { onMobile: "edge-tab" } : this.config.smartHide;
1077
+ if (window.matchMedia("(max-width: 768px)").matches && smart.onMobile === "edge-tab") {
1078
+ return "edge-tab";
1079
+ }
1080
+ return this.config.trigger;
1081
+ }
1082
+ isMobileSmartHidden() {
1083
+ if (!this.config.smartHide || typeof window === "undefined") return false;
1084
+ const smart = this.config.smartHide === true ? { onMobile: "edge-tab" } : this.config.smartHide;
1085
+ return window.matchMedia("(max-width: 768px)").matches && smart.onMobile === "hide";
1086
+ }
1087
+ detectEnvironment() {
1088
+ const host = typeof location !== "undefined" ? location.hostname : "";
1089
+ if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".local")) return "development";
1090
+ if (/\b(staging|stage|preview|dev)\b/i.test(host)) return "staging";
1091
+ return "production";
1092
+ }
1093
+ isRouteHidden() {
1094
+ if (!this.config.hideOnRoutes.length || typeof location === "undefined") return false;
1095
+ return this.config.hideOnRoutes.some((route) => location.pathname.includes(route));
1096
+ }
957
1097
  getTheme() {
958
1098
  if (this.config.theme !== "auto") return this.config.theme;
959
1099
  if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches) {
@@ -969,24 +1109,29 @@ var MushiWidget = class {
969
1109
  const style = document.createElement("style");
970
1110
  style.textContent = getWidgetStyles(theme);
971
1111
  this.shadow.appendChild(style);
972
- const trigger = document.createElement("button");
973
- trigger.className = `mushi-trigger ${pos}`;
974
- trigger.textContent = this.config.triggerText;
975
- trigger.setAttribute("aria-label", t.widget.trigger);
976
- trigger.setAttribute("aria-haspopup", "dialog");
977
- trigger.setAttribute("aria-expanded", String(this.isOpen));
978
- trigger.style.zIndex = String(this.config.zIndex);
979
- trigger.addEventListener("click", () => {
980
- if (this.isOpen) this.close();
981
- else this.open();
982
- });
983
- this.shadow.appendChild(trigger);
1112
+ if (this.shouldRenderTrigger()) {
1113
+ const effectiveTrigger = this.effectiveTrigger();
1114
+ const trigger = document.createElement("button");
1115
+ trigger.className = `mushi-trigger ${pos}${effectiveTrigger === "edge-tab" ? " edge-tab" : ""}${this.triggerShrunk ? " shrunk" : ""}`;
1116
+ trigger.textContent = this.config.triggerText;
1117
+ trigger.setAttribute("aria-label", t.widget.trigger);
1118
+ trigger.setAttribute("aria-haspopup", "dialog");
1119
+ trigger.setAttribute("aria-expanded", String(this.isOpen));
1120
+ trigger.style.zIndex = String(this.config.zIndex);
1121
+ this.applyInsetVars(trigger);
1122
+ trigger.addEventListener("click", () => {
1123
+ if (this.isOpen) this.close();
1124
+ else this.open();
1125
+ });
1126
+ this.shadow.appendChild(trigger);
1127
+ }
984
1128
  const panel = document.createElement("div");
985
1129
  panel.className = `mushi-panel ${pos}${this.isOpen ? " open" : " closed"}`;
986
1130
  panel.setAttribute("role", "dialog");
987
1131
  panel.setAttribute("aria-modal", "true");
988
1132
  panel.setAttribute("aria-label", t.widget.title);
989
1133
  panel.style.zIndex = String(this.config.zIndex + 1);
1134
+ this.applyInsetVars(panel);
990
1135
  if (this.isOpen) {
991
1136
  panel.innerHTML = this.renderStep();
992
1137
  this.shadow.appendChild(panel);
@@ -994,6 +1139,20 @@ var MushiWidget = class {
994
1139
  this.trapFocus(panel);
995
1140
  }
996
1141
  }
1142
+ applyInsetVars(el) {
1143
+ const { inset } = this.config;
1144
+ if (!this.config.respectSafeArea) {
1145
+ ["top", "right", "bottom", "left"].forEach((edge) => {
1146
+ if (inset[edge] === void 0) el.style.setProperty(`--mushi-${edge}`, "24px");
1147
+ });
1148
+ }
1149
+ ["top", "right", "bottom", "left"].forEach((edge) => {
1150
+ const value = inset[edge];
1151
+ if (value === void 0) return;
1152
+ el.style.setProperty(`--mushi-${edge}`, value === "auto" ? "auto" : `${value}px`);
1153
+ });
1154
+ el.style.setProperty("--mushi-safe-area", this.config.respectSafeArea ? "1" : "0");
1155
+ }
997
1156
  renderStep() {
998
1157
  switch (this.step) {
999
1158
  case "category":
@@ -2109,6 +2268,21 @@ function createInstance(config) {
2109
2268
  open() {
2110
2269
  widget.open();
2111
2270
  },
2271
+ openWith(category) {
2272
+ widget.open({ category });
2273
+ },
2274
+ show() {
2275
+ widget.showTrigger();
2276
+ },
2277
+ hide() {
2278
+ widget.hideTrigger();
2279
+ },
2280
+ attachTo(selectorOrElement, options) {
2281
+ return widget.attachTo(selectorOrElement, options);
2282
+ },
2283
+ setTrigger(trigger) {
2284
+ widget.setTrigger(trigger);
2285
+ },
2112
2286
  close() {
2113
2287
  widget.close();
2114
2288
  },
@@ -2184,11 +2358,14 @@ function createInstance(config) {
2184
2358
  return sdk;
2185
2359
  }
2186
2360
  function mergeRuntimeConfig(config, runtime) {
2361
+ const nativeTrigger = runtime.native?.triggerMode;
2362
+ const widgetTrigger = runtime.widget?.trigger ?? (nativeTrigger === "none" || nativeTrigger === "shake" ? "manual" : void 0);
2187
2363
  return {
2188
2364
  ...config,
2189
2365
  widget: {
2190
2366
  ...config.widget,
2191
- ...runtime.widget
2367
+ ...runtime.widget,
2368
+ ...widgetTrigger ? { trigger: widgetTrigger } : {}
2192
2369
  },
2193
2370
  capture: {
2194
2371
  ...config.capture,
@@ -2244,6 +2421,16 @@ function createNoopInstance() {
2244
2421
  },
2245
2422
  updateConfig: () => {
2246
2423
  },
2424
+ openWith: () => {
2425
+ },
2426
+ show: () => {
2427
+ },
2428
+ hide: () => {
2429
+ },
2430
+ attachTo: () => () => {
2431
+ },
2432
+ setTrigger: () => {
2433
+ },
2247
2434
  destroy: () => {
2248
2435
  instance = null;
2249
2436
  },