@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 +4 -0
- package/README.md +41 -0
- package/SECURITY.md +74 -0
- package/dist/index.cjs +256 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +256 -69
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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 {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
},
|