@mushi-mushi/react-native 0.16.0 → 0.17.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
@@ -58,12 +58,12 @@ packages/
58
58
  react-native/ React Native SDK (MIT)
59
59
  cli/ CLI tool (MIT)
60
60
  mcp/ MCP server for coding agents (MIT)
61
- server/ Supabase Edge Functions (BSL)
62
- agents/ Agentic fix pipeline (BSL)
63
- verify/ Fix verification (BSL)
61
+ server/ Supabase Edge Functions (AGPLv3)
62
+ agents/ Agentic fix pipeline (AGPLv3)
63
+ verify/ Fix verification (AGPLv3)
64
64
  apps/
65
65
  admin/ Admin dashboard (React + Tailwind)
66
- docs/ Documentation site (planned)
66
+ docs/ Documentation site (Nextra — apps/docs)
67
67
  tooling/
68
68
  eslint-config/ Shared ESLint flat config
69
69
  tsconfig/ Shared TypeScript configs
@@ -81,6 +81,30 @@ tooling/
81
81
  ```
82
82
  6. Open a pull request
83
83
 
84
+ ### Documentation (docs-as-code)
85
+
86
+ If your PR changes **user-visible SDK or CLI behavior**, update docs in the **same PR**:
87
+
88
+ | Surface | When to update |
89
+ | --- | --- |
90
+ | `packages/*/README.md` | npm-facing install/API/config changes for that package |
91
+ | `apps/docs/content/**/*.mdx` | Long-form guides, quickstarts, admin console docs |
92
+ | Root `README.md` | Marketing stats only when counts change — run `pnpm check:docs-stats` |
93
+
94
+ Guardrails (run locally before pushing):
95
+
96
+ ```bash
97
+ pnpm check:docs-stats # root README stat badges
98
+ pnpm check:sdk-version-matrix # apps/docs/content/sdks/index.mdx versions
99
+ pnpm check:internal-doc-links # dead /foo links in MDX
100
+ pnpm check:onboarding-drift # phantom MCP env vars / stale tool names
101
+ pnpm check:bundle-docs # @mushi-mushi/web size-limit vs README
102
+ pnpm gen:llms-txt # regenerate apps/docs/public/llms.txt after nav changes
103
+ pnpm gen:mcp-tools-doc # regenerate sdks/mcp-tools.generated.mdx from catalog.ts
104
+ ```
105
+
106
+ README changes on npm packages only appear on [npmjs.com](https://www.npmjs.com) after the next **Changesets publish**.
107
+
84
108
  ## Changesets
85
109
 
86
110
  We use [Changesets](https://github.com/changesets/changesets) for versioning. If your PR modifies a published package (`core`, `web`, `react`, `vue`, `svelte`, `angular`, `react-native`, `cli`, `mcp`), add a changeset:
@@ -93,13 +117,30 @@ Select the affected packages, the semver bump type, and write a summary. The cha
93
117
 
94
118
  ## Release flow
95
119
 
96
- Releases are fully automated. Maintainers don't run `npm publish` by hand.
120
+ Releases are fully automated once the version PR is merged. Maintainers don't run
121
+ `npm publish` by hand for routine bumps. See **[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md)**
122
+ for the full production runbook (edge functions, admin SPA, docs site, migrations).
97
123
 
98
124
  1. PRs land on `master` with one or more changeset files in `.changeset/`.
99
- 2. `release.yml` runs on every push to `master`. It opens (or updates) a `chore: version packages` PR that bumps every affected `package.json`, rolls up the changelogs, and deletes the consumed changesets.
100
- 3. Merging that "Version Packages" PR re-fires `release.yml`. The publish step authenticates to npm via **OpenID Connect (OIDC) Trusted Publishers** — no long-lived `NPM_TOKEN` is exchanged — and every tarball ships with a **Sigstore provenance attestation** uploaded to the public transparency log.
125
+ Orphaned changesets (only ignored packages) fail `pnpm check:changeset-orphans`.
126
+ 2. `release.yml` runs on every push to `master`. It opens (or updates) a
127
+ **`chore: version packages`** PR on `changeset-release/master` that bumps
128
+ affected `package.json` files, rolls up changelogs, and deletes consumed
129
+ changesets.
130
+ 3. **Wait for CI on the version PR.** If checks don't appear (bot-branch
131
+ suppression), push an empty commit to `changeset-release/master`. If
132
+ `check:sdk-version-matrix` fails, run `pnpm gen:sdk-version-matrix` on that
133
+ branch and commit `apps/docs/content/sdks/index.mdx`.
134
+ 4. Merge the version PR. The publish step uses **OIDC Trusted Publishers** +
135
+ Sigstore provenance (with `NPM_TOKEN` only for brand-new package bootstrap).
136
+ 5. **Manually dispatch Release** unless a run starts within ~2 minutes:
137
+ **Actions → Release → Run workflow → `master`**. Squash-merges attributed to
138
+ `github-actions[bot]` usually suppress the auto `push` trigger — manual
139
+ dispatch is the normal path, not a fallback of last resort.
101
140
 
102
- If GitHub's anti-loop protection suppresses the auto re-fire (the squash merge can be attributed to `github-actions[bot]`), trigger the workflow manually: **Actions → release → Run workflow → master**.
141
+ ```bash
142
+ gh workflow run release.yml --ref master
143
+ ```
103
144
 
104
145
  ### Adding a brand-new publishable package
105
146
 
@@ -146,7 +187,7 @@ chore: bump dependencies
146
187
  ## License
147
188
 
148
189
  - SDK packages are MIT — your contributions will be MIT-licensed
149
- - Server/agents/verify are BSL 1.1 — contributions to those packages fall under BSL
190
+ - Server/agents/verify are AGPLv3 — contributions to those packages fall under AGPLv3
150
191
 
151
192
  ## Questions?
152
193
 
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # @mushi-mushi/react-native
2
2
 
3
+ > **Your AI wrote it. Mushi tells you why it broke.**
4
+ > Plain-English diagnosis + a paste-ready fix, right inside Cursor. MIT-licensed SDKs · self-hostable · no second LLM key.
5
+
3
6
  React Native SDK for [Mushi Mushi](https://www.npmjs.com/package/mushi-mushi) — shake-to-report widget, offline queue, and the closed-loop lesson layer for iOS + Android.
4
7
 
5
8
  > **One-command setup:** `npx mushi-mushi` auto-detects React Native / Expo and installs this package with the right env vars.
@@ -91,27 +94,59 @@ import { MushiBottomSheet, MushiFloatingButton } from '@mushi-mushi/react-native
91
94
 
92
95
  ## What gets captured
93
96
 
94
- - Device info (platform, OS version, screen dimensions)
97
+ - Device info (platform, OS version, screen dimensions) + a stable device `fingerprintHash`
98
+ - A per-launch `sessionId` so every report from the same run is grouped
99
+ - `sdkPackage` / `sdkVersion` / `appVersion` so the console can flag outdated installs
95
100
  - Console logs (monkey-patched `console.*`)
96
101
  - Network requests (fetch interceptor, headers redacted)
97
102
  - Navigation events (React Navigation integration, optional)
103
+ - A 50-entry breadcrumb ring buffer → sent as `breadcrumbs` + a derived repro `timeline` (SDK lifecycle, screen changes via `setScreen`, and host crumbs via `addBreadcrumb`)
104
+ - Screenshot of the current screen (optional — see below)
98
105
  - Offline queue via `@react-native-async-storage/async-storage` — reports survive force-close
99
106
 
107
+ ## Screenshots
108
+
109
+ When `react-native-view-shot` is installed, the SDK captures the current screen **before** the report sheet overlays it, and shows the user a thumbnail they can remove before submitting. Enabled by default; turn off with `capture: { screenshot: false }`.
110
+
111
+ ```bash
112
+ npx expo install react-native-view-shot # v4+ on Expo SDK 55; v5+ for Fabric / New Architecture
113
+ ```
114
+
115
+ > **Masking sensitive screens.** `captureScreen()` grabs whatever is on screen, which on a finance/health app can include balances or PII. The user always reviews and can remove the thumbnail before submit, but for screens that should never be captured, set `capture: { screenshot: false }` while they're focused, or use `expo-screen-capture` to mark the view secure. Field-level masking is on the roadmap.
116
+
117
+ ## Identifying the reporter
118
+
119
+ Call `identify()` / `setUser()` whenever auth state changes so reports show a human name instead of an anonymous token in the console:
120
+
121
+ ```tsx
122
+ const mushi = useMushi()
123
+ mushi.identify(user.id, { email: user.email, name: user.displayName, provider: 'supabase' })
124
+ // or, web-SDK style:
125
+ mushi.setUser({ id: user.id, email: user.email, name: user.displayName, provider: 'supabase' })
126
+ ```
127
+
128
+ Mushi stores only a SHA-256 hash of the email (the host app owns the raw PII) and shows the `name` as the reporter's display name.
129
+
100
130
  ## Peer dependencies
101
131
 
102
132
  - `react` >= 18
103
133
  - `react-native` >= 0.72
104
134
  - `@react-navigation/native` >= 6 (optional — navigation capture)
105
135
  - `@react-native-async-storage/async-storage` >= 1.19 (optional — offline queue)
136
+ - `react-native-view-shot` >= 3.8 (optional — screenshot capture; v5+ for New Architecture / Fabric)
106
137
  - `expo-sensors` (optional — shake trigger only)
107
138
 
108
139
  ## Changelog highlights
109
140
 
141
+ - **v0.17.0** — Report payload parity with the web SDK: nested `metadata.user` identity (fixes the anonymous-token reporter on the server), `setUser()` alias, per-launch `sessionId`, `sdkPackage`/`sdkVersion`/`appVersion`, device `fingerprintHash`, and a breadcrumb ring buffer → `breadcrumbs` + repro `timeline`. `capture.screenshot` toggle + `addBreadcrumb()`. Bottom sheet restyled to match the web widget.
110
142
  - **v0.11.0** — Hermes compatibility fix: replaced `new Function()` dynamic imports with lazy `require()` for `@react-native-community/netinfo` and `expo-sensors`. Enables `trigger: 'button'` safely on all RN 0.84+ Hermes/AOT builds. Adds `MushiTrigger` headless component.
111
143
  - **v0.8.x** — Shake-to-report listener, offline queue, navigation capture.
112
144
 
113
145
  See the full [CHANGELOG](https://github.com/kensaurus/mushi-mushi/blob/master/packages/react-native/CHANGELOG.md).
114
146
 
147
+ Maintainers: report ingest contract and empty-state semantics are documented in
148
+ [`docs/report-ingest-contract.md`](../../docs/report-ingest-contract.md).
149
+
115
150
  ## License
116
151
 
117
152
  MIT
package/dist/index.cjs CHANGED
@@ -47,7 +47,7 @@ module.exports = __toCommonJS(index_exports);
47
47
 
48
48
  // src/provider.tsx
49
49
  var import_react3 = require("react");
50
- var import_core = require("@mushi-mushi/core");
50
+ var import_core3 = require("@mushi-mushi/core");
51
51
 
52
52
  // src/capture/console-capture.ts
53
53
  function setupConsoleCapture(maxEntries = 100) {
@@ -164,6 +164,30 @@ function getDeviceInfo() {
164
164
  };
165
165
  }
166
166
 
167
+ // src/capture/fingerprint.ts
168
+ function nonCryptoHashHex(input) {
169
+ let hash = 0;
170
+ for (let i = 0; i < input.length; i++) {
171
+ hash = (hash << 5) - hash + input.charCodeAt(i);
172
+ hash |= 0;
173
+ }
174
+ return `rnfp_${(hash >>> 0).toString(16).padStart(8, "0")}`;
175
+ }
176
+ function getDeviceFingerprintHash(device) {
177
+ const inputs = {
178
+ platform: device.platform,
179
+ osVersion: String(device.osVersion ?? ""),
180
+ screenWidth: device.screenWidth,
181
+ screenHeight: device.screenHeight,
182
+ pixelRatio: device.pixelRatio,
183
+ locale: device.locale ?? "",
184
+ timezone: device.timezone ?? ""
185
+ };
186
+ const serialised = JSON.stringify(inputs);
187
+ if (!device.platform && !device.screenWidth) return null;
188
+ return nonCryptoHashHex(serialised);
189
+ }
190
+
167
191
  // src/storage/async-storage-queue.ts
168
192
  var QUEUE_KEY = "@mushi:offline_queue";
169
193
  var AsyncStorageQueue = class {
@@ -317,10 +341,17 @@ function sleep(ms) {
317
341
  // src/components/MushiBottomSheet.tsx
318
342
  var import_react = require("react");
319
343
  var import_react_native2 = require("react-native");
344
+ var import_core = require("@mushi-mushi/core");
320
345
  var import_jsx_runtime = require("react/jsx-runtime");
321
346
  var { height: SCREEN_HEIGHT } = import_react_native2.Dimensions.get("window");
322
347
  var SHEET_HEIGHT = SCREEN_HEIGHT * 0.55;
323
348
  var DISMISS_THRESHOLD = 80;
349
+ var NEON = {
350
+ accent: "#0FFF50",
351
+ accentHover: "#00E646",
352
+ ink: "#0a1a0a",
353
+ border: "#00C43A"
354
+ };
324
355
  var CATEGORIES = [
325
356
  { key: "bug", emoji: "\u{1F41B}", label: "Bug" },
326
357
  { key: "slow", emoji: "\u{1F422}", label: "Slow" },
@@ -467,7 +498,8 @@ var MushiBottomSheet = ({
467
498
  }
468
499
  };
469
500
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
470
- const colors = dark ? { bg: "#1c1c1e", text: "#f2f2f7", sub: "#8e8e93", card: "#2c2c2e", accent: "#0a84ff", border: "#38383a", backdrop: "rgba(0,0,0,0.6)", disabled: "#3a3a3c", disabledText: "#636366" } : { bg: "#ffffff", text: "#1c1c1e", sub: "#8e8e93", card: "#f2f2f7", accent: "#007aff", border: "#e5e5ea", backdrop: "rgba(0,0,0,0.35)", disabled: "#d1d1d6", disabledText: "#8e8e93" };
501
+ const pal = (0, import_core.mushiPalette)(dark ? "dark" : "light");
502
+ const colors = dark ? { bg: pal.paper, text: pal.ink, sub: pal.inkMuted, card: pal.paperRaised, accent: NEON.accent, accentInk: NEON.ink, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.6)", disabled: "#3a3a3c", disabledText: pal.inkFaint } : { bg: pal.paperRaised, text: pal.ink, sub: pal.inkMuted, card: pal.paper, accent: NEON.accent, accentInk: NEON.ink, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.35)", disabled: "#d1d1d6", disabledText: pal.inkFaint };
471
503
  const canSubmit = !!category && description.trim().length > 0 && phase === "form";
472
504
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Modal, { visible, transparent: true, animationType: "none", statusBarTranslucent: true, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
473
505
  import_react_native2.KeyboardAvoidingView,
@@ -492,6 +524,10 @@ var MushiBottomSheet = ({
492
524
  ],
493
525
  children: [
494
526
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { ...panResponder.panHandlers, style: s.handleArea, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: [s.handle, { backgroundColor: colors.sub }] }) }),
527
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: [s.brandHeader, { backgroundColor: NEON.accent, borderBottomColor: NEON.border }], children: [
528
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.brandEyebrow, { color: NEON.ink }], children: "MUSHI \xB7 BETA" }),
529
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.brandTitle, { color: NEON.ink }], children: sheetTab === "inbox" ? "Your reports" : sheetTab === "community" ? "Community" : "Report an issue" })
530
+ ] }),
495
531
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.tabRow, children: ["report", "inbox", "community"].map((tab) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
496
532
  import_react_native2.TouchableOpacity,
497
533
  {
@@ -501,7 +537,7 @@ var MushiBottomSheet = ({
501
537
  else setSelectedReportId(null);
502
538
  },
503
539
  style: [s.tabBtn, sheetTab === tab && { borderBottomColor: colors.accent }],
504
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.tabLabel, { color: sheetTab === tab ? colors.accent : colors.sub }], children: tab === "report" ? "Report" : tab === "inbox" ? "Inbox" : "\u{1F310}" })
540
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.tabLabel, { color: sheetTab === tab ? colors.text : colors.sub }], children: tab === "report" ? "Report" : tab === "inbox" ? "Your reports" : "Community" })
505
541
  },
506
542
  tab
507
543
  )) }),
@@ -514,7 +550,7 @@ var MushiBottomSheet = ({
514
550
  VERIFIABLE_STATUSES.has(
515
551
  inboxReports.find((r) => r.id === selectedReportId)?.status ?? ""
516
552
  ) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.verifyRow, children: [
517
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.verifyBtn, { backgroundColor: colors.accent }], onPress: () => submitFeedback("confirms"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.submitText, children: "Yes, fixed" }) }),
553
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.verifyBtn, { backgroundColor: colors.accent }], onPress: () => submitFeedback("confirms"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.submitText, { color: colors.accentInk }], children: "Yes, fixed" }) }),
518
554
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.verifyBtn, { backgroundColor: colors.border }], onPress: () => submitFeedback("not_fixed"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.submitText, { color: colors.text }], children: "Not fixed" }) })
519
555
  ] }),
520
556
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -527,12 +563,11 @@ var MushiBottomSheet = ({
527
563
  onChangeText: setReplyText
528
564
  }
529
565
  ),
530
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.submitBtn, { backgroundColor: colors.accent }], onPress: sendReply, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.submitText, children: "Send" }) })
566
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.submitBtn, { backgroundColor: colors.accent }], onPress: sendReply, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.submitText, { color: colors.accentInk }], children: "Send" }) })
531
567
  ] }) : inboxReports.map((r) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.TouchableOpacity, { style: [s.inboxRow, { borderColor: colors.border }], onPress: () => openThread(r.id), children: [
532
568
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.text, fontWeight: "600" }, numberOfLines: 1, children: (r.summary ?? r.description ?? "Report").slice(0, 60) }),
533
569
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 11 }, children: r.status })
534
570
  ] }, r.id)) }) : sheetTab === "community" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.ScrollView, { style: s.body, keyboardShouldPersistTaps: "handled", children: [
535
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.title, { color: colors.text }], children: "\u{1F310} Mushi Community" }),
536
571
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 13, marginBottom: 16, lineHeight: 18 }, children: "Sign in to track your reports across apps and see the global leaderboard. No password needed." }),
537
572
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
538
573
  import_react_native2.TextInput,
@@ -546,7 +581,7 @@ var MushiBottomSheet = ({
546
581
  textContentType: "emailAddress"
547
582
  }
548
583
  ),
549
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.submitBtn, { backgroundColor: colors.accent }], children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.submitText, children: "Send sign-in link \u2192" }) }),
584
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.submitBtn, { backgroundColor: colors.accent }], children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.submitText, { color: colors.accentInk }], children: "Send sign-in link \u2192" }) }),
550
585
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 11, textAlign: "center", marginTop: 12 }, children: "We'll email you a one-tap sign-in link. No password, ever." })
551
586
  ] }) : phase === "sent" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.sentWrap, children: [
552
587
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.sentEmoji], children: "\u2705" }),
@@ -558,7 +593,7 @@ var MushiBottomSheet = ({
558
593
  keyboardShouldPersistTaps: "handled",
559
594
  contentContainerStyle: { paddingBottom: 24 },
560
595
  children: [
561
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.title, { color: colors.text }], children: "Report an issue" }),
596
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.stepLabel, { color: colors.sub }], children: "What kind of issue?" }),
562
597
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.catRow, children: CATEGORIES.map((c) => {
563
598
  const active = category === c.key;
564
599
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -580,7 +615,7 @@ var MushiBottomSheet = ({
580
615
  {
581
616
  style: [
582
617
  s.catLabel,
583
- { color: active ? "#fff" : colors.text }
618
+ { color: active ? colors.accentInk : colors.text }
584
619
  ],
585
620
  children: c.label
586
621
  }
@@ -646,7 +681,7 @@ var MushiBottomSheet = ({
646
681
  s.submitBtn,
647
682
  { backgroundColor: canSubmit ? colors.accent : colors.disabled }
648
683
  ],
649
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.submitText, !canSubmit && { color: colors.disabledText }], children: phase === "sending" ? "Sending\u2026" : "Submit" })
684
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.submitText, { color: canSubmit ? colors.accentInk : colors.disabledText }], children: phase === "sending" ? "Sending\u2026" : "Submit report" })
650
685
  }
651
686
  )
652
687
  ]
@@ -689,6 +724,24 @@ var s = import_react_native2.StyleSheet.create({
689
724
  borderRadius: 3,
690
725
  opacity: 0.5
691
726
  },
727
+ brandHeader: {
728
+ paddingHorizontal: 20,
729
+ paddingTop: 10,
730
+ paddingBottom: 12,
731
+ borderBottomWidth: 1.5
732
+ },
733
+ brandEyebrow: {
734
+ fontSize: 10,
735
+ fontWeight: "700",
736
+ letterSpacing: 1.2,
737
+ marginBottom: 2,
738
+ opacity: 0.7
739
+ },
740
+ brandTitle: {
741
+ fontSize: 18,
742
+ fontWeight: "800",
743
+ letterSpacing: -0.2
744
+ },
692
745
  body: {
693
746
  paddingHorizontal: 20,
694
747
  paddingTop: 4
@@ -698,6 +751,14 @@ var s = import_react_native2.StyleSheet.create({
698
751
  fontWeight: "700",
699
752
  marginBottom: 16
700
753
  },
754
+ stepLabel: {
755
+ fontSize: 12,
756
+ fontWeight: "600",
757
+ letterSpacing: 0.2,
758
+ marginTop: 8,
759
+ marginBottom: 10,
760
+ textTransform: "uppercase"
761
+ },
701
762
  catRow: {
702
763
  flexDirection: "row",
703
764
  justifyContent: "space-between",
@@ -826,6 +887,7 @@ var s = import_react_native2.StyleSheet.create({
826
887
  // src/components/MushiFloatingButton.tsx
827
888
  var import_react2 = require("react");
828
889
  var import_react_native3 = require("react-native");
890
+ var import_core2 = require("@mushi-mushi/core");
829
891
  var import_jsx_runtime2 = require("react/jsx-runtime");
830
892
  var FAB_SIZE = 56;
831
893
  var EDGE_PADDING = 12;
@@ -877,7 +939,7 @@ var MushiFloatingButton = ({
877
939
  const currentX = (0, import_react2.useRef)(baseX);
878
940
  const currentY = (0, import_react2.useRef)(0);
879
941
  const isDragging = (0, import_react2.useRef)(false);
880
- const accentColor = accent ?? (dark ? "#0a84ff" : "#007aff");
942
+ const accentColor = accent ?? (0, import_core2.mushiPalette)(dark ? "dark" : "light").accent;
881
943
  (0, import_react2.useEffect)(() => {
882
944
  if (!persist) return;
883
945
  void loadStoredPos().then((pos) => {
@@ -987,7 +1049,7 @@ var MushiFloatingButton = ({
987
1049
  onStartShouldSetResponder: () => !draggable,
988
1050
  onResponderGrant: handlePressIn,
989
1051
  onResponderRelease: handlePressOut,
990
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.Animated.Text, { style: s2.emoji, children: "\u{1F41B}" })
1052
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.Animated.Text, { style: s2.emoji, children: import_core2.MUSHI_COPY.triggerText })
991
1053
  }
992
1054
  );
993
1055
  };
@@ -1010,14 +1072,53 @@ var s2 = import_react_native3.StyleSheet.create({
1010
1072
  }
1011
1073
  });
1012
1074
 
1075
+ // src/version.ts
1076
+ var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1077
+ var MUSHI_SDK_VERSION = "0.17.0" ? "0.17.0" : "0.17.0";
1078
+
1013
1079
  // src/provider.tsx
1014
1080
  var import_jsx_runtime3 = require("react/jsx-runtime");
1015
1081
  var MushiContext = (0, import_react3.createContext)(null);
1016
- function MushiProvider({ children, ...config }) {
1082
+ function breadcrumbToTimelineKind(category) {
1083
+ switch (category) {
1084
+ case "navigation":
1085
+ return "route";
1086
+ case "ui.click":
1087
+ case "ui.tap":
1088
+ return "click";
1089
+ case "xhr":
1090
+ case "fetch":
1091
+ case "network":
1092
+ return "request";
1093
+ default:
1094
+ return "log";
1095
+ }
1096
+ }
1097
+ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1098
+ const proc = globalThis.process;
1099
+ const envProjectId = proc?.env?.MUSHI_PROJECT_ID || proc?.env?.EXPO_PUBLIC_MUSHI_PROJECT_ID;
1100
+ const envApiKey = proc?.env?.MUSHI_API_KEY || proc?.env?.EXPO_PUBLIC_MUSHI_API_KEY;
1101
+ const config = {
1102
+ ...envProjectId ? { projectId: envProjectId } : {},
1103
+ ...envApiKey ? { apiKey: envApiKey } : {},
1104
+ ...configProp,
1105
+ ...barePropConfig
1106
+ };
1107
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
1108
+ if (!config.projectId || !config.apiKey) {
1109
+ console.warn(
1110
+ "[MushiProvider] Missing projectId or apiKey \u2014 set MUSHI_PROJECT_ID / MUSHI_API_KEY (or EXPO_PUBLIC_* equivalents) or pass them as props."
1111
+ );
1112
+ }
1113
+ }
1017
1114
  const consoleRef = (0, import_react3.useRef)(null);
1018
1115
  const networkRef = (0, import_react3.useRef)(null);
1019
1116
  const queueRef = (0, import_react3.useRef)(null);
1020
1117
  const apiClientRef = (0, import_react3.useRef)(null);
1118
+ const sessionIdRef = (0, import_react3.useRef)(`sess_${(0, import_core3.newUuid)()}`);
1119
+ const breadcrumbsRef = (0, import_react3.useRef)(
1120
+ (0, import_core3.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1121
+ );
1021
1122
  const [sheetVisible, setSheetVisible] = (0, import_react3.useState)(false);
1022
1123
  const [sheetScreenshot, setSheetScreenshot] = (0, import_react3.useState)(null);
1023
1124
  const reporterTokenRef = (0, import_react3.useRef)(`rn-${config.projectId}-anon`);
@@ -1028,6 +1129,8 @@ function MushiProvider({ children, ...config }) {
1028
1129
  resolveReporterTokenReady = resolve;
1029
1130
  })
1030
1131
  );
1132
+ const userTokenRef = (0, import_react3.useRef)(null);
1133
+ const pageContextRef = (0, import_react3.useRef)(null);
1031
1134
  const rawEndpoint = config.apiEndpoint ?? config.endpoint;
1032
1135
  if (rawEndpoint !== void 0 && rawEndpoint.trim() === "") {
1033
1136
  throw new Error(
@@ -1037,7 +1140,7 @@ function MushiProvider({ children, ...config }) {
1037
1140
  if (config.endpoint && !config.apiEndpoint && __DEV__) {
1038
1141
  console.warn("[MushiProvider] `endpoint` is deprecated \u2014 use `apiEndpoint` instead.");
1039
1142
  }
1040
- const apiEndpoint = rawEndpoint ?? import_core.DEFAULT_API_ENDPOINT;
1143
+ const apiEndpoint = rawEndpoint ?? import_core3.DEFAULT_API_ENDPOINT;
1041
1144
  (0, import_react3.useEffect)(() => {
1042
1145
  if (config.capture?.console !== false) {
1043
1146
  consoleRef.current = setupConsoleCapture(config.capture?.maxConsoleEntries);
@@ -1045,10 +1148,11 @@ function MushiProvider({ children, ...config }) {
1045
1148
  if (config.capture?.network !== false) {
1046
1149
  networkRef.current = setupNetworkCapture(config.capture?.maxNetworkEntries, apiEndpoint);
1047
1150
  }
1048
- apiClientRef.current = (0, import_core.createApiClient)({
1151
+ apiClientRef.current = (0, import_core3.createApiClient)({
1049
1152
  projectId: config.projectId,
1050
1153
  apiKey: config.apiKey,
1051
- apiEndpoint
1154
+ apiEndpoint,
1155
+ getUserToken: () => userTokenRef.current
1052
1156
  });
1053
1157
  queueRef.current = new AsyncStorageQueue({
1054
1158
  maxSize: config.storage?.maxQueueSize,
@@ -1061,6 +1165,12 @@ function MushiProvider({ children, ...config }) {
1061
1165
  });
1062
1166
  queueRef.current.flush().catch(() => {
1063
1167
  });
1168
+ breadcrumbsRef.current.add({
1169
+ category: "lifecycle",
1170
+ level: "info",
1171
+ message: "Mushi SDK initialised",
1172
+ data: { sdk: MUSHI_SDK_PACKAGE, version: MUSHI_SDK_VERSION }
1173
+ });
1064
1174
  (async () => {
1065
1175
  const TOKEN_KEY = "@mushi:reporter_token";
1066
1176
  try {
@@ -1069,14 +1179,14 @@ function MushiProvider({ children, ...config }) {
1069
1179
  if (existing && existing.startsWith("mushi_")) {
1070
1180
  reporterTokenRef.current = existing;
1071
1181
  } else {
1072
- const fresh = `mushi_${(0, import_core.newUuid)()}`;
1182
+ const fresh = `mushi_${(0, import_core3.newUuid)()}`;
1073
1183
  reporterTokenRef.current = fresh;
1074
1184
  await AsyncStorage.setItem(TOKEN_KEY, fresh);
1075
1185
  }
1076
1186
  resolveReporterTokenReady();
1077
1187
  } catch {
1078
1188
  if (reporterTokenRef.current === `rn-${config.projectId}-anon`) {
1079
- reporterTokenRef.current = `mushi_${(0, import_core.newUuid)()}`;
1189
+ reporterTokenRef.current = `mushi_${(0, import_core3.newUuid)()}`;
1080
1190
  }
1081
1191
  resolveReporterTokenReady();
1082
1192
  }
@@ -1102,6 +1212,17 @@ function MushiProvider({ children, ...config }) {
1102
1212
  return () => unsubscribe?.();
1103
1213
  }, []);
1104
1214
  const open = (0, import_react3.useCallback)(() => {
1215
+ breadcrumbsRef.current.add({
1216
+ category: "lifecycle",
1217
+ level: "info",
1218
+ message: "Widget opened",
1219
+ ...screenRef.current?.name ? { data: { screen: screenRef.current.name } } : {}
1220
+ });
1221
+ if (config.capture?.screenshot === false) {
1222
+ setSheetScreenshot(null);
1223
+ setSheetVisible(true);
1224
+ return;
1225
+ }
1105
1226
  let vshot = null;
1106
1227
  try {
1107
1228
  vshot = require("react-native-view-shot");
@@ -1119,7 +1240,7 @@ function MushiProvider({ children, ...config }) {
1119
1240
  }).finally(() => {
1120
1241
  setSheetVisible(true);
1121
1242
  });
1122
- }, []);
1243
+ }, [config.capture?.screenshot]);
1123
1244
  const close = (0, import_react3.useCallback)(() => setSheetVisible(false), []);
1124
1245
  const attachTo = (0, import_react3.useCallback)(() => ({ onPress: open }), [open]);
1125
1246
  (0, import_react3.useEffect)(() => {
@@ -1153,16 +1274,37 @@ function MushiProvider({ children, ...config }) {
1153
1274
  const submitReport = (0, import_react3.useCallback)(
1154
1275
  async (data) => {
1155
1276
  const deviceInfo = getDeviceInfo();
1277
+ breadcrumbsRef.current.add({
1278
+ category: "lifecycle",
1279
+ level: "info",
1280
+ message: "Report submitted",
1281
+ data: { category: data.category }
1282
+ });
1156
1283
  const contextMeta = {};
1157
- if (userRef.current?.id) contextMeta.userId = userRef.current.id;
1284
+ if (userRef.current?.id) {
1285
+ contextMeta.user = {
1286
+ id: userRef.current.id,
1287
+ ...userRef.current.email ? { email: userRef.current.email } : {},
1288
+ ...userRef.current.name ? { name: userRef.current.name } : {},
1289
+ ...userRef.current.provider ? { provider: userRef.current.provider } : {}
1290
+ };
1291
+ contextMeta.userId = userRef.current.id;
1292
+ }
1158
1293
  if (userRef.current?.email) contextMeta.userEmail = userRef.current.email;
1159
1294
  if (userRef.current?.name) contextMeta.userName = userRef.current.name;
1160
1295
  if (screenRef.current?.name) contextMeta.screenName = screenRef.current.name;
1161
1296
  if (screenRef.current?.route) contextMeta.screenRoute = screenRef.current.route;
1162
1297
  if (screenRef.current?.feature) contextMeta.screenFeature = screenRef.current.feature;
1163
1298
  const merged = Object.keys(metadataRef.current).length > 0 ? { ...contextMeta, ...metadataRef.current } : Object.keys(contextMeta).length > 0 ? contextMeta : void 0;
1299
+ const breadcrumbs = breadcrumbsRef.current.getAll();
1300
+ const timeline = breadcrumbs.map((c) => ({
1301
+ ts: c.timestamp,
1302
+ kind: breadcrumbToTimelineKind(c.category),
1303
+ payload: { message: c.message, level: c.level, ...c.data ?? {} }
1304
+ }));
1305
+ const fingerprintHash = getDeviceFingerprintHash(deviceInfo);
1164
1306
  const report = {
1165
- id: (0, import_core.newUuid)(),
1307
+ id: (0, import_core3.newUuid)(),
1166
1308
  projectId: config.projectId,
1167
1309
  category: data.category,
1168
1310
  description: data.description,
@@ -1183,7 +1325,14 @@ function MushiProvider({ children, ...config }) {
1183
1325
  })) ?? [],
1184
1326
  screenshotDataUrl: data.screenshotDataUrl,
1185
1327
  metadata: merged,
1328
+ sessionId: sessionIdRef.current,
1186
1329
  reporterToken: reporterTokenRef.current,
1330
+ ...fingerprintHash ? { fingerprintHash } : {},
1331
+ ...deviceInfo.appVersion ? { appVersion: deviceInfo.appVersion } : {},
1332
+ sdkPackage: MUSHI_SDK_PACKAGE,
1333
+ sdkVersion: MUSHI_SDK_VERSION,
1334
+ ...breadcrumbs.length > 0 ? { breadcrumbs } : {},
1335
+ ...timeline.length > 0 ? { timeline } : {},
1187
1336
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1188
1337
  };
1189
1338
  const client = apiClientRef.current;
@@ -1241,11 +1390,53 @@ function MushiProvider({ children, ...config }) {
1241
1390
  provider: traits?.provider
1242
1391
  };
1243
1392
  },
1393
+ setUser(user) {
1394
+ userRef.current = {
1395
+ id: user.id,
1396
+ email: user.email,
1397
+ name: user.name,
1398
+ provider: user.provider
1399
+ };
1400
+ },
1401
+ identifyWithToken(token) {
1402
+ userTokenRef.current = token && typeof token === "string" ? token : null;
1403
+ if (userTokenRef.current) {
1404
+ const claims = (0, import_core3.parseIdentityToken)(userTokenRef.current);
1405
+ if (claims?.sub) {
1406
+ userRef.current = {
1407
+ id: claims.sub,
1408
+ ...claims.email ? { email: claims.email } : {},
1409
+ ...claims.name ? { name: claims.name } : {}
1410
+ };
1411
+ }
1412
+ }
1413
+ },
1414
+ publishPageContext(context) {
1415
+ pageContextRef.current = context && context.route ? context : null;
1416
+ },
1244
1417
  setMetadata(key, value) {
1245
1418
  metadataRef.current[key] = value;
1246
1419
  },
1247
1420
  setScreen(screen) {
1421
+ const prev = screenRef.current?.name;
1248
1422
  screenRef.current = screen;
1423
+ if (screen.name && screen.name !== prev) {
1424
+ breadcrumbsRef.current.add({
1425
+ category: "navigation",
1426
+ level: "info",
1427
+ message: `Navigated to ${screen.name}`,
1428
+ // Always include `route` (fallback to the screen name) so the
1429
+ // admin repro-timeline renders the destination instead of
1430
+ // "unknown route" when the host only set a screen name.
1431
+ data: {
1432
+ route: screen.route ?? screen.name,
1433
+ ...screen.feature ? { feature: screen.feature } : {}
1434
+ }
1435
+ });
1436
+ }
1437
+ },
1438
+ addBreadcrumb(crumb) {
1439
+ breadcrumbsRef.current.add(crumb);
1249
1440
  },
1250
1441
  // Reporter API — scoped to the device's persistent reporter token
1251
1442
  async listMyReports() {