@multiplatform.one/core 7.1.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/core",
3
- "version": "7.1.0",
3
+ "version": "7.2.0",
4
4
  "description": "Core framework shell, provider composition, and layout factories for multiplatform.one",
5
5
  "keywords": [
6
6
  "app",
@@ -42,15 +42,16 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@tanstack/devtools-event-client": "^0.4.3",
45
+ "@tanstack/devtools-utils": "^0.4.0",
46
+ "@tanstack/pacer-devtools": "1.3.0",
45
47
  "@tanstack/react-devtools": "^0.10.2",
46
48
  "@tanstack/react-form-devtools": "0.2.22",
47
- "@tanstack/react-pacer-devtools": "^0.7.0",
48
49
  "react-cookie": "^8.1.2",
49
- "@multiplatform.one/i18n": "7.1.0",
50
- "@multiplatform.one/logger": "7.1.0",
51
- "@multiplatform.one/platform": "7.1.0",
52
- "@multiplatform.one/store": "7.1.0",
53
- "@multiplatform.one/theme": "7.1.0"
50
+ "@multiplatform.one/platform": "7.2.0",
51
+ "@multiplatform.one/logger": "7.2.0",
52
+ "@multiplatform.one/i18n": "7.2.0",
53
+ "@multiplatform.one/theme": "7.2.0",
54
+ "@multiplatform.one/store": "7.2.0"
54
55
  },
55
56
  "devDependencies": {
56
57
  "@sentry/react": "^10.51.0",
@@ -68,8 +69,8 @@
68
69
  "tamagui": "2.7.6",
69
70
  "typescript": "~5.9.3",
70
71
  "vitest": "^4.1.5",
71
- "@multiplatform.one/frappe": "7.1.0",
72
- "@multiplatform.one/web3": "7.1.0"
72
+ "@multiplatform.one/web3": "7.2.0",
73
+ "@multiplatform.one/frappe": "7.2.0"
73
74
  },
74
75
  "peerDependencies": {
75
76
  "@sentry/react": "^9.0.0",
@@ -85,10 +86,10 @@
85
86
  "react-native-gesture-handler": ">=2.0.0",
86
87
  "react-native-safe-area-context": ">=4.0.0",
87
88
  "tamagui": "^2.0.0-rc",
88
- "@multiplatform.one/frappe": "^7.1.0",
89
- "@multiplatform.one/frappe-ui": "^7.1.0",
90
- "@multiplatform.one/keycloak": "^7.1.0",
91
- "@multiplatform.one/web3": "^7.1.0"
89
+ "@multiplatform.one/frappe": "^7.2.0",
90
+ "@multiplatform.one/frappe-ui": "^7.2.0",
91
+ "@multiplatform.one/keycloak": "^7.2.0",
92
+ "@multiplatform.one/web3": "^7.2.0"
92
93
  },
93
94
  "peerDependenciesMeta": {
94
95
  "@multiplatform.one/web3": {
@@ -52,6 +52,15 @@ export function followPageOriginForLoopback(configured: string): string {
52
52
  export interface TanstackConfig {
53
53
  /** Show React Query devtools (web only, ignored on native and in Storybook). */
54
54
  debug?: boolean;
55
+ /**
56
+ * Shake the device to open the devtools shell (web only; requires
57
+ * `debug`). Defaults to true whenever the devtools mount. On iOS 13+
58
+ * WebKit the motion permission is requested once from the user's first
59
+ * tap; Android and desktop need no permission (desktop simply never
60
+ * fires devicemotion — use the Ctrl/Cmd+` shortcut there). Set false to
61
+ * keep the keyboard shortcut as the only trigger.
62
+ */
63
+ shakeToOpen?: boolean;
55
64
  /** Options forwarded to the QueryClient constructor. */
56
65
  queryClientOptions?: Record<string, unknown>;
57
66
  /**
@@ -70,6 +79,22 @@ export interface TanstackConfig {
70
79
  * Passed automatically by `createApp()` from the theme config.
71
80
  */
72
81
  tamaguiConfig?: unknown;
82
+ /**
83
+ * Theme COLOR names the devtools panel offers in its accent dropdown
84
+ * (e.g. `["", "blue", "green", "purple"]`; `""` is the base theme).
85
+ * The panel renders the dropdown only when this AND `onThemeColorChange`
86
+ * are both set, and only then does the panel's Randomize action touch the
87
+ * accent — otherwise the picker is inert.
88
+ */
89
+ themeColors?: string[];
90
+ /** The theme color the app currently has applied (`""` for the base). */
91
+ currentThemeColor?: string;
92
+ /**
93
+ * Apply a theme color the user picked. The app owns this because a theme
94
+ * color is app state, not devtools state: the panel changes nothing on its
95
+ * own, it hands the choice back.
96
+ */
97
+ onThemeColorChange?: (color: string) => void;
73
98
  }
74
99
 
75
100
  export interface KeycloakConfig {
@@ -1,15 +1,36 @@
1
1
  import React from "react";
2
- import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
2
+ // The `/production` subpaths carry the same implementations the main
3
+ // entries carry in development, but WITHOUT the NODE_ENV !== "development"
4
+ // no-op stubs the main entries dead-code themselves into in a production
5
+ // build. This whole file is lazy-loaded and gated on `tanstackConfig.debug`,
6
+ // so importing the real panels unconditionally is what lets a deployed
7
+ // build that opts in (debug: true) actually show devtools.
8
+ import { ReactQueryDevtools } from "@tanstack/react-query-devtools/production";
3
9
  import { TanStackDevtools } from "@tanstack/react-devtools";
4
- import { formDevtoolsPlugin } from "@tanstack/react-form-devtools";
5
- import { pacerDevtoolsPlugin } from "@tanstack/react-pacer-devtools";
10
+ import { formDevtoolsPlugin } from "@tanstack/react-form-devtools/production";
11
+ import { createReactPanel, createReactPlugin } from "@tanstack/devtools-utils/react";
12
+ import { PacerDevtoolsCore } from "@tanstack/pacer-devtools/production";
6
13
  import { ThemeDevtoolsPanel } from "@multiplatform.one/theme";
7
14
  import { storage } from "@multiplatform.one/store";
8
15
  import { useEffect, useState } from "react";
9
16
  import type { TanstackConfig } from "./CreateApp";
17
+ import { devtoolsOpenHotkey, useShakeToOpen } from "./devtoolsTrigger";
10
18
 
11
19
  const devtoolsSettingsKey = "tanstack_devtools_settings";
12
20
 
21
+ // @tanstack/react-pacer-devtools' /production entry is broken upstream
22
+ // (0.7.x): it re-exports its panel from ReactPacerDevtools.js, which pulls
23
+ // PacerDevtoolsCore from @tanstack/pacer-devtools' MAIN entry — the
24
+ // NODE_ENV-gated one — so its "production" plugin silently mounts the no-op
25
+ // core in a deployed bundle. Rebuild the plugin from the genuinely ungated
26
+ // /production core instead. devtoolsTrigger.spec.ts pins the resolved core
27
+ // as real (its unmount throws; the NoOp's is silent).
28
+ const [PacerDevtoolsPanel] = createReactPanel(PacerDevtoolsCore);
29
+ const [pacerDevtoolsPlugin] = createReactPlugin({
30
+ name: "TanStack Pacer",
31
+ Component: PacerDevtoolsPanel,
32
+ });
33
+
13
34
  /**
14
35
  * Sync the resolved color scheme into the TanStack Devtools settings
15
36
  * stored via the shared storage adapter. The devtools shell reads this on mount and
@@ -36,7 +57,7 @@ export interface TanstackDevtoolsProps {
36
57
  /**
37
58
  * Lazily-loaded devtools shell. All heavy devtools dependencies
38
59
  * (`@tanstack/react-devtools`, `@tanstack/react-query-devtools`,
39
- * `@tanstack/react-form-devtools`, `@tanstack/react-pacer-devtools`,
60
+ * `@tanstack/react-form-devtools`, `@tanstack/pacer-devtools`,
40
61
  * and `ThemeDevtoolsPanel`) live here so they are code-split away
41
62
  * from the production bundle.
42
63
  */
@@ -108,6 +129,11 @@ export default function TanstackDevtoolsWrapper({
108
129
 
109
130
  const frappePlugin = useFrappePlugin();
110
131
 
132
+ // Shake-to-open (devicemotion) — the phone-friendly counterpart to the
133
+ // keyboard shortcut. Ships whenever the devtools mount unless the app
134
+ // opts out with `tanstack: { shakeToOpen: false }`.
135
+ useShakeToOpen(tanstackConfig.shakeToOpen !== false);
136
+
111
137
  const plugins = [
112
138
  {
113
139
  id: "react-query",
@@ -119,11 +145,29 @@ export default function TanstackDevtoolsWrapper({
119
145
  {
120
146
  id: "theme",
121
147
  name: "Theme",
148
+ // The panel opens focused on Theme, so a shake (or the hotkey) lands
149
+ // straight on the theme flipper — the whole point of the trigger is
150
+ // to check that everything is themable. `defaultOpen` seeds the tab
151
+ // only on first open, when no layout is persisted yet; once the user
152
+ // moves to another tab their choice is restored on later opens.
153
+ defaultOpen: true,
122
154
  render: (_el: HTMLElement, props: { theme: "dark" | "light" }) => (
123
155
  <ThemeDevtoolsPanel
124
156
  useUserScheme={tanstackConfig.useUserScheme}
125
157
  devtoolsTheme={props.theme}
126
158
  tamaguiConfig={tanstackConfig.tamaguiConfig}
159
+ // The accent channel. `ThemeDevtoolsPanel` has taken these three
160
+ // props all along and core passed none of them, so the panel's
161
+ // accent dropdown never rendered and its Randomize action skipped
162
+ // the colour — a control that is present in the component and
163
+ // unreachable through the only wrapper that mounts it. They are
164
+ // forwarded, not invented here: a theme colour is app state, so the
165
+ // app supplies the list, the current value and the setter, exactly
166
+ // as it already supplies `useUserScheme`. Undefined stays undefined,
167
+ // so an app that wires nothing sees the panel it sees today.
168
+ themeColors={tanstackConfig.themeColors}
169
+ currentThemeColor={tanstackConfig.currentThemeColor}
170
+ onThemeColorChange={tanstackConfig.onThemeColorChange}
127
171
  />
128
172
  ),
129
173
  },
@@ -136,7 +180,7 @@ export default function TanstackDevtoolsWrapper({
136
180
  config={{
137
181
  defaultOpen: false,
138
182
  triggerHidden: true,
139
- openHotkey: ["CtrlOrMeta", "`"],
183
+ openHotkey: [...devtoolsOpenHotkey],
140
184
  theme: resolvedScheme,
141
185
  }}
142
186
  plugins={plugins}
@@ -0,0 +1,488 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import {
10
+ __resetMotionArming,
11
+ armDevtoolsMotion,
12
+ createMotionPermissionFlow,
13
+ createShakeDetector,
14
+ devtoolsArmMotionEvent,
15
+ devtoolsMotionArmedEvent,
16
+ devtoolsOpenHotkey,
17
+ devtoolsShellPluginId,
18
+ devtoolsTriggerToggledEvent,
19
+ isMotionPermissionRequired,
20
+ motionMagnitude,
21
+ openDevtoolsShell,
22
+ requestMotionPermissionFromGesture,
23
+ shakeDefaults,
24
+ } from "./devtoolsTrigger";
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+
28
+ /** A single devicemotion-shaped sample with the given magnitude on x. */
29
+ function sample(magnitude: number) {
30
+ return { acceleration: { x: magnitude, y: 0, z: 0 } };
31
+ }
32
+
33
+ describe("motionMagnitude", () => {
34
+ it("prefers the gravity-excluded acceleration reading", () => {
35
+ expect(
36
+ motionMagnitude({
37
+ acceleration: { x: 3, y: 4, z: 0 },
38
+ accelerationIncludingGravity: { x: 100, y: 0, z: 0 },
39
+ }),
40
+ ).toBe(5);
41
+ });
42
+
43
+ it("falls back to accelerationIncludingGravity minus standard gravity", () => {
44
+ expect(
45
+ motionMagnitude({
46
+ acceleration: null,
47
+ accelerationIncludingGravity: { x: 0, y: 0, z: 9.81 },
48
+ }),
49
+ ).toBeCloseTo(0);
50
+ expect(
51
+ motionMagnitude({
52
+ accelerationIncludingGravity: { x: 0, y: 0, z: 29.81 },
53
+ }),
54
+ ).toBeCloseTo(20);
55
+ });
56
+
57
+ it("treats missing axes as zero", () => {
58
+ expect(motionMagnitude({ acceleration: { x: 13, y: null, z: null } })).toBe(13);
59
+ });
60
+
61
+ it("returns null when the event carries no data (all-null desktop events)", () => {
62
+ expect(motionMagnitude({})).toBeNull();
63
+ expect(motionMagnitude({ acceleration: null, accelerationIncludingGravity: null })).toBeNull();
64
+ expect(
65
+ motionMagnitude({
66
+ acceleration: { x: null, y: null, z: null },
67
+ accelerationIncludingGravity: { x: null, y: null, z: null },
68
+ }),
69
+ ).toBeNull();
70
+ });
71
+ });
72
+
73
+ describe("createShakeDetector", () => {
74
+ it("fires after three fast peaks inside the window", () => {
75
+ const onShake = vi.fn();
76
+ const detector = createShakeDetector({ onShake });
77
+ detector.handleMotion(sample(20), 0);
78
+ detector.handleMotion(sample(20), 150);
79
+ expect(onShake).not.toHaveBeenCalled();
80
+ detector.handleMotion(sample(20), 300);
81
+ expect(onShake).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it("ignores samples at or below the threshold", () => {
85
+ const onShake = vi.fn();
86
+ const detector = createShakeDetector({ onShake });
87
+ for (let t = 0; t < 2000; t += 100) {
88
+ detector.handleMotion(sample(shakeDefaults.threshold), t);
89
+ }
90
+ expect(onShake).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it("counts one swing as one peak (min gap between peaks)", () => {
94
+ const onShake = vi.fn();
95
+ const detector = createShakeDetector({ onShake });
96
+ // Six samples of the same 100ms swing, 20ms apart — only two peaks
97
+ // (t=0 and t=100) survive the 80ms gap, so no fire.
98
+ for (let t = 0; t <= 100; t += 20) {
99
+ detector.handleMotion(sample(25), t);
100
+ }
101
+ expect(onShake).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("does not fire when peaks fall outside the rolling window", () => {
105
+ const onShake = vi.fn();
106
+ const detector = createShakeDetector({ onShake });
107
+ detector.handleMotion(sample(20), 0);
108
+ detector.handleMotion(sample(20), 600);
109
+ detector.handleMotion(sample(20), 1200); // t=0 peak has left the 1000ms window
110
+ expect(onShake).not.toHaveBeenCalled();
111
+ detector.handleMotion(sample(20), 1700); // t=600 is also gone; 1200+1700 = 2 peaks
112
+ expect(onShake).not.toHaveBeenCalled();
113
+ detector.handleMotion(sample(20), 1800); // 1200, 1700, 1800 all inside
114
+ expect(onShake).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ it("goes dead for the cooldown after firing, then re-arms", () => {
118
+ const onShake = vi.fn();
119
+ const detector = createShakeDetector({ onShake });
120
+ detector.handleMotion(sample(20), 0);
121
+ detector.handleMotion(sample(20), 100);
122
+ detector.handleMotion(sample(20), 200);
123
+ expect(onShake).toHaveBeenCalledTimes(1);
124
+ // A continued violent shake inside the 2000ms cooldown is dropped.
125
+ detector.handleMotion(sample(25), 500);
126
+ detector.handleMotion(sample(25), 700);
127
+ detector.handleMotion(sample(25), 900);
128
+ detector.handleMotion(sample(25), 2100);
129
+ expect(onShake).toHaveBeenCalledTimes(1);
130
+ // After the cooldown (200 + 2000 = 2200) a fresh three-peak shake
131
+ // fires again; nothing from the cooldown window carries over.
132
+ detector.handleMotion(sample(25), 2300);
133
+ detector.handleMotion(sample(25), 2450);
134
+ detector.handleMotion(sample(25), 2600);
135
+ expect(onShake).toHaveBeenCalledTimes(2);
136
+ });
137
+
138
+ it("reset clears peaks and cooldown", () => {
139
+ const onShake = vi.fn();
140
+ const detector = createShakeDetector({ onShake });
141
+ detector.handleMotion(sample(20), 0);
142
+ detector.handleMotion(sample(20), 100);
143
+ detector.reset();
144
+ detector.handleMotion(sample(20), 200);
145
+ expect(onShake).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it("honors custom tuning", () => {
149
+ const onShake = vi.fn();
150
+ const detector = createShakeDetector({ onShake, threshold: 5, peakCount: 2, windowMs: 300 });
151
+ detector.handleMotion(sample(6), 0);
152
+ detector.handleMotion(sample(6), 200);
153
+ expect(onShake).toHaveBeenCalledTimes(1);
154
+ });
155
+ });
156
+
157
+ describe("createMotionPermissionFlow", () => {
158
+ it("grants immediately when no requestPermission exists (Android/desktop)", () => {
159
+ const onGranted = vi.fn();
160
+ const flow = createMotionPermissionFlow({ onGranted });
161
+ expect(flow.state).toBe("granted");
162
+ expect(onGranted).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it("waits for a gesture on iOS and grants on 'granted'", async () => {
166
+ const onGranted = vi.fn();
167
+ const requestPermission = vi.fn().mockResolvedValue("granted");
168
+ const flow = createMotionPermissionFlow({ requestPermission, onGranted });
169
+ expect(flow.state).toBe("awaiting-gesture");
170
+ expect(onGranted).not.toHaveBeenCalled();
171
+ await flow.handleGesture();
172
+ expect(flow.state).toBe("granted");
173
+ expect(onGranted).toHaveBeenCalledTimes(1);
174
+ expect(requestPermission).toHaveBeenCalledTimes(1);
175
+ });
176
+
177
+ it("lands on denied (terminal for this load) on 'denied'", async () => {
178
+ const onGranted = vi.fn();
179
+ const onDenied = vi.fn();
180
+ const flow = createMotionPermissionFlow({
181
+ requestPermission: vi.fn().mockResolvedValue("denied"),
182
+ onGranted,
183
+ onDenied,
184
+ });
185
+ await flow.handleGesture();
186
+ expect(flow.state).toBe("denied");
187
+ expect(onGranted).not.toHaveBeenCalled();
188
+ expect(onDenied).toHaveBeenCalledTimes(1);
189
+ });
190
+
191
+ it("treats a rejection (called outside a gesture / insecure context) as denied", async () => {
192
+ const flow = createMotionPermissionFlow({
193
+ requestPermission: vi.fn().mockRejectedValue(new Error("NotAllowedError")),
194
+ onGranted: vi.fn(),
195
+ });
196
+ await flow.handleGesture();
197
+ expect(flow.state).toBe("denied");
198
+ });
199
+
200
+ it("no-ops repeat gestures after settling and while a request is in flight", async () => {
201
+ const onGranted = vi.fn();
202
+ let resolve!: (v: string) => void;
203
+ const requestPermission = vi.fn(
204
+ () =>
205
+ new Promise<string>((r) => {
206
+ resolve = r;
207
+ }),
208
+ );
209
+ const flow = createMotionPermissionFlow({ requestPermission, onGranted });
210
+ const first = flow.handleGesture();
211
+ expect(flow.state).toBe("requesting");
212
+ await flow.handleGesture(); // in flight — must not re-ask
213
+ expect(requestPermission).toHaveBeenCalledTimes(1);
214
+ resolve("granted");
215
+ await first;
216
+ await flow.handleGesture(); // settled — must not re-ask
217
+ expect(requestPermission).toHaveBeenCalledTimes(1);
218
+ expect(onGranted).toHaveBeenCalledTimes(1);
219
+ });
220
+ });
221
+
222
+ describe("openDevtoolsShell", () => {
223
+ afterEach(() => {
224
+ (globalThis as { __TANSTACK_EVENT_TARGET__?: EventTarget | null }).__TANSTACK_EVENT_TARGET__ =
225
+ null;
226
+ });
227
+
228
+ it("emits the shell's trigger-toggled open event on the devtools bus", () => {
229
+ const bus = new EventTarget();
230
+ (globalThis as { __TANSTACK_EVENT_TARGET__?: EventTarget | null }).__TANSTACK_EVENT_TARGET__ =
231
+ bus;
232
+ // Play the shell's side of the connect handshake.
233
+ bus.addEventListener("tanstack-connect", () => {
234
+ bus.dispatchEvent(new CustomEvent("tanstack-connect-success"));
235
+ });
236
+ const received: Array<{ type: string; payload: { isOpen: boolean } }> = [];
237
+ bus.addEventListener("tanstack-dispatch-event", (event) => {
238
+ received.push((event as CustomEvent).detail);
239
+ });
240
+ openDevtoolsShell();
241
+ expect(received).toHaveLength(1);
242
+ expect(received[0]).toMatchObject({
243
+ type: `${devtoolsShellPluginId}:${devtoolsTriggerToggledEvent}`,
244
+ payload: { isOpen: true },
245
+ });
246
+ // Once connected, later shakes emit directly (open-only, never close).
247
+ openDevtoolsShell();
248
+ expect(received).toHaveLength(2);
249
+ expect(received[1]?.payload).toEqual({ isOpen: true });
250
+ });
251
+ });
252
+
253
+ describe("upstream contract (installed packages)", () => {
254
+ // The trigger-toggled bus event is an internal implementation detail of
255
+ // @tanstack/devtools, not documented public API. Pin it against the
256
+ // installed packages so an upstream rename fails tests instead of
257
+ // silently breaking shake-to-open.
258
+ /**
259
+ * Locate an installed package dir regardless of linker layout: walk
260
+ * ancestor node_modules (hoisted puts everything at the repo root),
261
+ * then fall back to a sibling of a package that depends on it
262
+ * (pnpm's isolated virtual store keeps dependencies as siblings).
263
+ */
264
+ function findInstalled(pkg: string, siblingOf?: string): string | null {
265
+ let dir = here;
266
+ for (let depth = 0; depth < 8; depth++) {
267
+ const candidate = join(dir, "node_modules", pkg);
268
+ if (existsSync(candidate)) return realpathSync(candidate);
269
+ const parent = dirname(dir);
270
+ if (parent === dir) break;
271
+ dir = parent;
272
+ }
273
+ if (siblingOf) {
274
+ const sibling = join(siblingOf, "..", pkg.split("/").pop() as string);
275
+ if (existsSync(sibling)) return realpathSync(sibling);
276
+ }
277
+ return null;
278
+ }
279
+
280
+ it("@tanstack/devtools still listens for the trigger-toggled event", () => {
281
+ const reactDevtoolsDir = findInstalled("@tanstack/react-devtools");
282
+ expect(reactDevtoolsDir).toBeTruthy();
283
+ const devtoolsDir = findInstalled("@tanstack/devtools", reactDevtoolsDir as string);
284
+ expect(devtoolsDir).toBeTruthy();
285
+ const chunkDir = join(devtoolsDir as string, "dist", "devtools");
286
+ const chunks = readdirSync(chunkDir).filter((f) => f.endsWith(".js"));
287
+ const hit = chunks.some((f) =>
288
+ readFileSync(join(chunkDir, f), "utf8").includes(`"${devtoolsTriggerToggledEvent}"`),
289
+ );
290
+ expect(hit).toBe(true);
291
+ });
292
+
293
+ it("@tanstack/devtools-client still uses the tanstack-devtools-core plugin id", () => {
294
+ const reactDevtoolsDir = findInstalled("@tanstack/react-devtools");
295
+ const devtoolsDir = findInstalled("@tanstack/devtools", reactDevtoolsDir as string);
296
+ const clientDir = findInstalled("@tanstack/devtools-client", devtoolsDir as string);
297
+ expect(clientDir).toBeTruthy();
298
+ const source = readFileSync(join(clientDir as string, "dist", "esm", "index.js"), "utf8");
299
+ expect(source).toContain(`pluginId: "${devtoolsShellPluginId}"`);
300
+ });
301
+
302
+ it("@tanstack/pacer-devtools/production resolves the REAL core, not the NoOp", async () => {
303
+ // A NoOp core (constructCoreClass's second class) has silent
304
+ // mount/unmount; the real core throws "Devtools is not mounted" when
305
+ // unmounted before mount. That behavioral difference — not an import
306
+ // string — is what proves a deployed bundle gets a working Pacer panel.
307
+ const pacerDir = findInstalled("@tanstack/pacer-devtools");
308
+ expect(pacerDir).toBeTruthy();
309
+
310
+ // Sanity-check the trap first: vitest runs with NODE_ENV !== "development",
311
+ // the same condition a deployed bundle sees, so the MAIN entry's gate
312
+ // must hand back the NoOp here. If this half fails, upstream removed the
313
+ // gate and the /production workaround can be dropped.
314
+ const mainEntry = await import(
315
+ pathToFileURL(join(pacerDir as string, "dist", "index.js")).href
316
+ );
317
+ expect(() => new mainEntry.PacerDevtoolsCore().unmount()).not.toThrow();
318
+
319
+ // The entry the shell actually imports must be the real core.
320
+ const prodEntry = await import(
321
+ pathToFileURL(join(pacerDir as string, "dist", "production", "index.js")).href
322
+ );
323
+ expect(() => new prodEntry.PacerDevtoolsCore().unmount()).toThrow(/not mounted/i);
324
+ });
325
+ });
326
+
327
+ describe("motion arming (deliberate, app-driven grant)", () => {
328
+ const realWindow = (globalThis as { window?: unknown }).window;
329
+ const realMotion = (globalThis as { DeviceMotionEvent?: unknown }).DeviceMotionEvent;
330
+
331
+ afterEach(() => {
332
+ __resetMotionArming();
333
+ (globalThis as { window?: unknown }).window = realWindow;
334
+ (globalThis as { DeviceMotionEvent?: unknown }).DeviceMotionEvent = realMotion;
335
+ vi.restoreAllMocks();
336
+ });
337
+
338
+ /** Stand up a window (an EventTarget) and, optionally, an iOS motion ctor. */
339
+ function stubEnv(requestPermission?: () => Promise<string>) {
340
+ const win = new EventTarget();
341
+ (globalThis as { window?: unknown }).window = win;
342
+ (globalThis as { DeviceMotionEvent?: unknown }).DeviceMotionEvent = requestPermission
343
+ ? { requestPermission }
344
+ : {};
345
+ return win as EventTarget & { addEventListener: EventTarget["addEventListener"] };
346
+ }
347
+
348
+ it("isMotionPermissionRequired is true only when requestPermission exists (iOS)", () => {
349
+ stubEnv(async () => "granted");
350
+ expect(isMotionPermissionRequired()).toBe(true);
351
+ stubEnv(undefined);
352
+ expect(isMotionPermissionRequired()).toBe(false);
353
+ });
354
+
355
+ it("grants immediately with no prompt on Android/desktop and broadcasts granted", async () => {
356
+ const win = stubEnv(undefined);
357
+ const armed: Array<boolean> = [];
358
+ win.addEventListener(devtoolsMotionArmedEvent, (e) =>
359
+ armed.push((e as CustomEvent).detail.granted),
360
+ );
361
+ const state = await requestMotionPermissionFromGesture();
362
+ expect(state).toBe("granted");
363
+ expect(armed).toEqual([true]);
364
+ });
365
+
366
+ it("asks once on iOS and broadcasts the granted outcome", async () => {
367
+ const requestPermission = vi.fn().mockResolvedValue("granted");
368
+ const win = stubEnv(requestPermission);
369
+ const armed: Array<boolean> = [];
370
+ win.addEventListener(devtoolsMotionArmedEvent, (e) =>
371
+ armed.push((e as CustomEvent).detail.granted),
372
+ );
373
+ const state = await requestMotionPermissionFromGesture();
374
+ expect(requestPermission).toHaveBeenCalledTimes(1);
375
+ expect(state).toBe("granted");
376
+ expect(armed).toEqual([true]);
377
+ });
378
+
379
+ it("broadcasts a denial and does not re-prompt once granted", async () => {
380
+ const denied = vi.fn().mockResolvedValue("denied");
381
+ const win = stubEnv(denied);
382
+ const armed: Array<boolean> = [];
383
+ win.addEventListener(devtoolsMotionArmedEvent, (e) =>
384
+ armed.push((e as CustomEvent).detail.granted),
385
+ );
386
+ expect(await requestMotionPermissionFromGesture()).toBe("denied");
387
+ expect(armed).toEqual([false]);
388
+
389
+ // A later grant flips it; after that the flow short-circuits without
390
+ // touching requestPermission again (Safari remembers the answer).
391
+ __resetMotionArming();
392
+ const granted = vi.fn().mockResolvedValue("granted");
393
+ stubEnv(granted);
394
+ expect(await requestMotionPermissionFromGesture()).toBe("granted");
395
+ expect(await requestMotionPermissionFromGesture()).toBe("granted");
396
+ expect(granted).toHaveBeenCalledTimes(1);
397
+ });
398
+
399
+ it("armDevtoolsMotion dispatches the arm event on window", () => {
400
+ const win = stubEnv(async () => "granted");
401
+ const seen = vi.fn();
402
+ win.addEventListener(devtoolsArmMotionEvent, seen);
403
+ armDevtoolsMotion();
404
+ expect(seen).toHaveBeenCalledTimes(1);
405
+ });
406
+ });
407
+
408
+ describe("devtools shell wiring", () => {
409
+ const shellSource = readFileSync(join(here, "TanstackDevtools.tsx"), "utf8");
410
+ const triggerSource = readFileSync(join(here, "devtoolsTrigger.ts"), "utf8");
411
+
412
+ it("opens focused on the Theme tab (defaultOpen on the theme plugin)", () => {
413
+ // The theme plugin object carries `defaultOpen: true` so a shake lands
414
+ // on the theme flipper on first open.
415
+ expect(shellSource).toMatch(/id:\s*"theme"[\s\S]*?defaultOpen:\s*true/);
416
+ });
417
+
418
+ it("arms iOS motion deliberately, never by grabbing an arbitrary tap", () => {
419
+ // The old build grabbed the first `pointerup` anywhere to self-grant,
420
+ // which popped the iOS prompt on a random tap. The trigger is now a
421
+ // deliberate arm event and the pointerup grab is gone.
422
+ expect(triggerSource).not.toContain('addEventListener("pointerup"');
423
+ expect(triggerSource).toContain("window.addEventListener(devtoolsArmMotionEvent");
424
+ expect(shellSource).toContain("useShakeToOpen(tanstackConfig.shakeToOpen !== false)");
425
+ });
426
+
427
+ it("keeps the existing Ctrl/Cmd+` shortcut", () => {
428
+ expect(devtoolsOpenHotkey).toEqual(["CtrlOrMeta", "`"]);
429
+ // The shell config must spread the pinned constant, not restate it.
430
+ expect(shellSource).toContain("openHotkey: [...devtoolsOpenHotkey]");
431
+ });
432
+
433
+ it("mounts shake-to-open with the config opt-out", () => {
434
+ expect(shellSource).toContain("useShakeToOpen(tanstackConfig.shakeToOpen !== false)");
435
+ });
436
+
437
+ it("forwards the accent channel to the theme panel", () => {
438
+ // `ThemeDevtoolsPanel` takes themeColors / currentThemeColor /
439
+ // onThemeColorChange and renders its accent dropdown only when the list
440
+ // and the setter are both present. The shell mounts that panel and used
441
+ // to pass none of the three, so the control existed and was unreachable
442
+ // through the only wrapper that mounts it.
443
+ for (const prop of ["themeColors", "currentThemeColor", "onThemeColorChange"]) {
444
+ expect(shellSource).toContain(`${prop}={tanstackConfig.${prop}}`);
445
+ }
446
+ });
447
+
448
+ it("keeps the accent channel app-owned — the shell invents no default", () => {
449
+ // A theme colour is app state. The shell forwards what the app gives it
450
+ // and nothing else, so an app that wires nothing sees the panel it saw
451
+ // before this change rather than a dropdown that changes nothing.
452
+ expect(shellSource).not.toMatch(/themeColors=\{(?!tanstackConfig\.themeColors\})/);
453
+ expect(shellSource).not.toMatch(
454
+ /onThemeColorChange=\{(?!tanstackConfig\.onThemeColorChange\})/,
455
+ );
456
+ });
457
+
458
+ it("loads the plugin panels through their production entries", () => {
459
+ // The main entries dead-code themselves into no-ops when
460
+ // NODE_ENV !== "development"; a deployed build needs /production.
461
+ expect(shellSource).toContain('"@tanstack/react-query-devtools/production"');
462
+ expect(shellSource).toContain('"@tanstack/react-form-devtools/production"');
463
+ // Pacer cannot go through @tanstack/react-pacer-devtools/production:
464
+ // that entry transitively imports @tanstack/pacer-devtools' NODE_ENV-gated
465
+ // MAIN entry, so its "production" plugin mounts the NoOp core. The shell
466
+ // rebuilds the plugin from the ungated core instead.
467
+ expect(shellSource).toContain('"@tanstack/pacer-devtools/production"');
468
+ expect(shellSource).toContain("createReactPanel(PacerDevtoolsCore)");
469
+ expect(shellSource).not.toContain('from "@tanstack/react-pacer-devtools');
470
+ });
471
+ });
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Type-level lock: the three accent fields on TanstackConfig ARE the panel's,
475
+ // so widening or renaming one upstream breaks the typecheck here instead of
476
+ // silently dropping the channel again.
477
+ // ---------------------------------------------------------------------------
478
+ type PanelProps = import("@multiplatform.one/theme").ThemeDevtoolsPanelProps;
479
+ type Cfg = import("./CreateApp").TanstackConfig;
480
+ type _AccentChannelMatches =
481
+ Pick<Cfg, "themeColors" | "currentThemeColor" | "onThemeColorChange"> extends Pick<
482
+ PanelProps,
483
+ "themeColors" | "currentThemeColor" | "onThemeColorChange"
484
+ >
485
+ ? true
486
+ : never;
487
+ const _accentChannelLock: _AccentChannelMatches = true;
488
+ void _accentChannelLock;
@@ -0,0 +1,385 @@
1
+ import { EventClient } from "@tanstack/devtools-event-client";
2
+ import { useEffect } from "react";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Keyboard trigger
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /**
9
+ * Keyboard shortcut that toggles the devtools shell (Ctrl+` / Cmd+`).
10
+ * Handled inside `@tanstack/react-devtools`, which skips editable targets
11
+ * (inputs, textareas, contentEditable), so it cannot collide with app
12
+ * typing. Exported so tests pin the binding.
13
+ */
14
+ export const devtoolsOpenHotkey = ["CtrlOrMeta", "`"] as const;
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Programmatic open — the same event the devtools shell's own trigger emits
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Plugin id of the TanStack Devtools shell's internal event client
22
+ * (`devtoolsEventClient` in `@tanstack/devtools-client`). The shell
23
+ * subscribes to `<pluginId>:trigger-toggled` and opens/closes on the
24
+ * payload's `isOpen`. The spec asserts both strings
25
+ * against the installed packages so an upstream rename fails the build
26
+ * instead of silently breaking shake-to-open.
27
+ */
28
+ export const devtoolsShellPluginId = "tanstack-devtools-core";
29
+
30
+ /** Event suffix the shell listens on for open/close state changes. */
31
+ export const devtoolsTriggerToggledEvent = "trigger-toggled";
32
+
33
+ interface DevtoolsShellEventMap {
34
+ "trigger-toggled": { isOpen: boolean };
35
+ }
36
+
37
+ let shellEventClient: EventClient<DevtoolsShellEventMap> | undefined;
38
+
39
+ /**
40
+ * Open the TanStack Devtools shell programmatically by emitting the same
41
+ * bus event the shell's own (hidden) trigger button uses. Open-only by
42
+ * design: a continued shake must not immediately re-close the panel —
43
+ * closing stays on the close button / Escape / the keyboard shortcut.
44
+ * The event client queues emits until the bus connects, so an early shake
45
+ * racing the shell mount is still delivered.
46
+ */
47
+ export function openDevtoolsShell() {
48
+ shellEventClient ??= new EventClient<DevtoolsShellEventMap>({
49
+ pluginId: devtoolsShellPluginId,
50
+ });
51
+ shellEventClient.emit(devtoolsTriggerToggledEvent, { isOpen: true });
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Shake detection — pure state machine over devicemotion samples
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const gravityMs2 = 9.81;
59
+
60
+ /** Defaults: deliberate shakes peak 15–25 m/s²; walking/bumps stay under ~8. */
61
+ export const shakeDefaults = {
62
+ /** m/s² a sample must exceed to count as a shake peak. */
63
+ threshold: 12,
64
+ /** Minimum ms between counted peaks (one swing = one peak, not many samples). */
65
+ minPeakGapMs: 80,
66
+ /** Peaks required inside the rolling window to fire. */
67
+ peakCount: 3,
68
+ /** Rolling window (ms) the peaks must land in. */
69
+ windowMs: 1000,
70
+ /** Dead time (ms) after firing before the detector re-arms. */
71
+ cooldownMs: 2000,
72
+ } as const;
73
+
74
+ interface MotionAxes {
75
+ x?: number | null;
76
+ y?: number | null;
77
+ z?: number | null;
78
+ }
79
+
80
+ /** Structural subset of DeviceMotionEvent the detector needs (testable in node). */
81
+ export interface MotionEventLike {
82
+ acceleration?: MotionAxes | null;
83
+ accelerationIncludingGravity?: MotionAxes | null;
84
+ }
85
+
86
+ /**
87
+ * Acceleration magnitude (m/s²) of a devicemotion sample. Prefers the
88
+ * gravity-excluded reading; falls back to the including-gravity reading
89
+ * minus standard gravity. Returns null when the event carries no data
90
+ * (some desktop browsers fire devicemotion with all-null axes).
91
+ */
92
+ export function motionMagnitude(event: MotionEventLike): number | null {
93
+ const a = event.acceleration;
94
+ if (a && (a.x != null || a.y != null || a.z != null)) {
95
+ return Math.hypot(a.x ?? 0, a.y ?? 0, a.z ?? 0);
96
+ }
97
+ const g = event.accelerationIncludingGravity;
98
+ if (g && (g.x != null || g.y != null || g.z != null)) {
99
+ return Math.abs(Math.hypot(g.x ?? 0, g.y ?? 0, g.z ?? 0) - gravityMs2);
100
+ }
101
+ return null;
102
+ }
103
+
104
+ export interface ShakeDetectorOptions {
105
+ onShake: () => void;
106
+ threshold?: number;
107
+ minPeakGapMs?: number;
108
+ peakCount?: number;
109
+ windowMs?: number;
110
+ cooldownMs?: number;
111
+ }
112
+
113
+ export interface ShakeDetector {
114
+ /** Feed a devicemotion sample. `now` is injectable for tests. */
115
+ handleMotion: (event: MotionEventLike, now?: number) => void;
116
+ /** Drop all peaks and cooldown state. */
117
+ reset: () => void;
118
+ }
119
+
120
+ /**
121
+ * Debounced shake detector: fires `onShake` when `peakCount` peaks above
122
+ * `threshold` (each at least `minPeakGapMs` apart) land inside a rolling
123
+ * `windowMs`, then goes dead for `cooldownMs` before re-arming.
124
+ */
125
+ export function createShakeDetector({
126
+ onShake,
127
+ threshold = shakeDefaults.threshold,
128
+ minPeakGapMs = shakeDefaults.minPeakGapMs,
129
+ peakCount = shakeDefaults.peakCount,
130
+ windowMs = shakeDefaults.windowMs,
131
+ cooldownMs = shakeDefaults.cooldownMs,
132
+ }: ShakeDetectorOptions): ShakeDetector {
133
+ let peaks: number[] = [];
134
+ let lastPeakAt = Number.NEGATIVE_INFINITY;
135
+ let cooldownUntil = Number.NEGATIVE_INFINITY;
136
+
137
+ function reset() {
138
+ peaks = [];
139
+ lastPeakAt = Number.NEGATIVE_INFINITY;
140
+ cooldownUntil = Number.NEGATIVE_INFINITY;
141
+ }
142
+
143
+ function handleMotion(event: MotionEventLike, now: number = Date.now()) {
144
+ if (now < cooldownUntil) return;
145
+ const magnitude = motionMagnitude(event);
146
+ if (magnitude == null || magnitude <= threshold) return;
147
+ if (now - lastPeakAt < minPeakGapMs) return;
148
+ lastPeakAt = now;
149
+ peaks = peaks.filter((at) => now - at <= windowMs);
150
+ peaks.push(now);
151
+ if (peaks.length < peakCount) return;
152
+ peaks = [];
153
+ lastPeakAt = Number.NEGATIVE_INFINITY;
154
+ cooldownUntil = now + cooldownMs;
155
+ onShake();
156
+ }
157
+
158
+ return { handleMotion, reset };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Motion permission — iOS 13+ WebKit gesture-gated grant flow
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export type MotionPermissionState = "granted" | "awaiting-gesture" | "requesting" | "denied";
166
+
167
+ export interface MotionPermissionFlow {
168
+ readonly state: MotionPermissionState;
169
+ /**
170
+ * Call from a user gesture (pointerup/click). Resolves once the
171
+ * permission settles; no-ops unless state is "awaiting-gesture".
172
+ */
173
+ handleGesture: () => Promise<void>;
174
+ }
175
+
176
+ /**
177
+ * Permission state machine. Platforms without
178
+ * `DeviceMotionEvent.requestPermission` (Android, desktop) grant
179
+ * immediately; iOS WebKit waits for a user gesture, asks once, and lands
180
+ * on granted or denied. A denial is terminal for this page load — Safari
181
+ * remembers the answer per site, so later loads resolve silently.
182
+ */
183
+ export function createMotionPermissionFlow({
184
+ requestPermission,
185
+ onGranted,
186
+ onDenied,
187
+ }: {
188
+ requestPermission?: () => Promise<string>;
189
+ onGranted: () => void;
190
+ onDenied?: () => void;
191
+ }): MotionPermissionFlow {
192
+ let state: MotionPermissionState = requestPermission ? "awaiting-gesture" : "granted";
193
+ if (state === "granted") onGranted();
194
+
195
+ async function handleGesture() {
196
+ if (state !== "awaiting-gesture" || !requestPermission) return;
197
+ state = "requesting";
198
+ let result: string;
199
+ try {
200
+ result = await requestPermission();
201
+ } catch {
202
+ // requestPermission rejects when called outside a user gesture or in
203
+ // an insecure context — treat as denied for this load.
204
+ result = "denied";
205
+ }
206
+ if (result === "granted") {
207
+ state = "granted";
208
+ onGranted();
209
+ } else {
210
+ state = "denied";
211
+ onDenied?.();
212
+ }
213
+ }
214
+
215
+ return {
216
+ get state() {
217
+ return state;
218
+ },
219
+ handleGesture,
220
+ };
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Motion arming — a DELIBERATE, app-driven grant (no arbitrary tap grab)
225
+ // ---------------------------------------------------------------------------
226
+
227
+ interface DeviceMotionEventConstructorLike {
228
+ requestPermission?: () => Promise<string>;
229
+ }
230
+
231
+ function motionEventCtor(): DeviceMotionEventConstructorLike | undefined {
232
+ return (globalThis as { DeviceMotionEvent?: DeviceMotionEventConstructorLike }).DeviceMotionEvent;
233
+ }
234
+
235
+ /**
236
+ * True only on the platform that gates devicemotion behind a user-gesture
237
+ * grant: iOS 13+ WebKit exposes `DeviceMotionEvent.requestPermission`.
238
+ * Android and desktop return false — they deliver motion events without a
239
+ * prompt, so shake-to-open arms itself there and no trigger UI is needed.
240
+ */
241
+ export function isMotionPermissionRequired(): boolean {
242
+ return typeof motionEventCtor()?.requestPermission === "function";
243
+ }
244
+
245
+ /**
246
+ * The window event an app dispatches from a DELIBERATE user gesture to arm
247
+ * shake-to-open on iOS (e.g. the SHC console's triple-tap on the build
248
+ * number in Settings › Estate). It is a window CustomEvent rather than an
249
+ * imported function on purpose: the trigger lives in a feature package that
250
+ * does not — and must not — depend on this core build. `dispatchEvent` is
251
+ * synchronous, so the `requestPermission()` this fires runs INSIDE the
252
+ * originating gesture's call stack, which is the whole reason iOS accepts
253
+ * it. `armDevtoolsMotion()` dispatches it for callers that CAN import core.
254
+ */
255
+ export const devtoolsArmMotionEvent = "mpo:devtools-arm-motion";
256
+
257
+ /**
258
+ * The window event core dispatches back once an arm attempt settles, so a
259
+ * trigger UI can report the honest outcome. `detail.granted` is true only
260
+ * when motion is now live.
261
+ */
262
+ export const devtoolsMotionArmedEvent = "mpo:devtools-motion-armed";
263
+
264
+ // The grant is a one-shot module latch plus a set of attachers (one per
265
+ // mounted `useShakeToOpen`). Once granted, a shell that mounts LATER — a
266
+ // colour-scheme change remounts it under a new key — attaches immediately
267
+ // instead of waiting for a second arm gesture.
268
+ let motionGranted = false;
269
+ const motionAttachers = new Set<() => void>();
270
+
271
+ function registerMotionAttacher(attach: () => void): () => void {
272
+ if (motionGranted) {
273
+ attach();
274
+ return () => {};
275
+ }
276
+ motionAttachers.add(attach);
277
+ return () => {
278
+ motionAttachers.delete(attach);
279
+ };
280
+ }
281
+
282
+ function grantAndAttachAll() {
283
+ motionGranted = true;
284
+ // Safe to iterate directly: `attach` adds a devicemotion listener and
285
+ // never mutates the set, and a shell mounting mid-grant sees
286
+ // motionGranted === true and attaches without registering.
287
+ for (const attach of motionAttachers) attach();
288
+ }
289
+
290
+ /** Test seam: forget the grant and drop attachers between cases. */
291
+ export function __resetMotionArming() {
292
+ motionGranted = false;
293
+ motionAttachers.clear();
294
+ }
295
+
296
+ /**
297
+ * Request motion permission from within a user gesture and, on grant,
298
+ * attach every waiting shake listener. Idempotent once granted. Resolves
299
+ * to the settled permission state; a denial is terminal for this page load
300
+ * (Safari remembers it per site). Broadcast the outcome on
301
+ * `devtoolsMotionArmedEvent` so a trigger UI can report it.
302
+ */
303
+ export async function requestMotionPermissionFromGesture(): Promise<MotionPermissionState> {
304
+ if (motionGranted) return "granted";
305
+ const ctor = motionEventCtor();
306
+ const flow = createMotionPermissionFlow({
307
+ // Invoke as a static method on the constructor — a detached reference
308
+ // throws in WebKit.
309
+ requestPermission:
310
+ typeof ctor?.requestPermission === "function" ? () => ctor.requestPermission!() : undefined,
311
+ onGranted: grantAndAttachAll,
312
+ });
313
+ // Non-iOS: the flow granted (and attached) synchronously in its
314
+ // constructor — nothing to await.
315
+ if (flow.state !== "granted") await flow.handleGesture();
316
+ if (typeof window !== "undefined") {
317
+ window.dispatchEvent(
318
+ new CustomEvent(devtoolsMotionArmedEvent, {
319
+ detail: { granted: flow.state === "granted" },
320
+ }),
321
+ );
322
+ }
323
+ return flow.state;
324
+ }
325
+
326
+ /**
327
+ * Arm shake-to-open programmatically. For callers that CAN import core
328
+ * (the app layer); a feature package instead dispatches
329
+ * `devtoolsArmMotionEvent` directly. Must be called from a user gesture on
330
+ * iOS. No-op on the server.
331
+ */
332
+ export function armDevtoolsMotion(): void {
333
+ if (typeof window === "undefined") return;
334
+ window.dispatchEvent(new Event(devtoolsArmMotionEvent));
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // React wiring
339
+ // ---------------------------------------------------------------------------
340
+
341
+ /**
342
+ * Shake-to-open for the devtools shell. Attaches a `devicemotion` listener
343
+ * and opens the shell on a detected shake. Desktop browsers deliver no
344
+ * devicemotion events, so the listener is inert there — the keyboard
345
+ * shortcut is the desktop path.
346
+ *
347
+ * iOS 13+ gates devicemotion behind a permission prompt that can ONLY be
348
+ * requested from a user gesture, and the first shake cannot self-grant. So
349
+ * on iOS the listener attaches only after the app arms it — by dispatching
350
+ * `devtoolsArmMotionEvent` from a deliberate gesture (the console wires the
351
+ * build-number triple-tap). Android/desktop attach immediately. SSR-safe;
352
+ * every listener is torn down on unmount.
353
+ */
354
+ export function useShakeToOpen(enabled: boolean, onShake: () => void = openDevtoolsShell) {
355
+ useEffect(() => {
356
+ if (!enabled || typeof window === "undefined") return;
357
+
358
+ const detector = createShakeDetector({ onShake });
359
+ const motionListener = (event: Event) =>
360
+ detector.handleMotion(event as unknown as MotionEventLike);
361
+ const attach = () => window.addEventListener("devicemotion", motionListener, { passive: true });
362
+ const detach = () => window.removeEventListener("devicemotion", motionListener);
363
+
364
+ if (!isMotionPermissionRequired()) {
365
+ // Android / desktop: no grant needed — listen right away.
366
+ attach();
367
+ return detach;
368
+ }
369
+
370
+ // iOS: attach now if a prior deliberate arm already granted; otherwise
371
+ // wait for the app's arm event (dispatched from a user gesture, so the
372
+ // permission request it fires stays inside that gesture's stack).
373
+ const removeAttacher = registerMotionAttacher(attach);
374
+ const armListener = () => {
375
+ void requestMotionPermissionFromGesture();
376
+ };
377
+ window.addEventListener(devtoolsArmMotionEvent, armListener);
378
+
379
+ return () => {
380
+ detach();
381
+ removeAttacher();
382
+ window.removeEventListener(devtoolsArmMotionEvent, armListener);
383
+ };
384
+ }, [enabled, onShake]);
385
+ }
@@ -15,6 +15,15 @@ export declare function followPageOriginForLoopback(configured: string): string;
15
15
  export interface TanstackConfig {
16
16
  /** Show React Query devtools (web only, ignored on native and in Storybook). */
17
17
  debug?: boolean;
18
+ /**
19
+ * Shake the device to open the devtools shell (web only; requires
20
+ * `debug`). Defaults to true whenever the devtools mount. On iOS 13+
21
+ * WebKit the motion permission is requested once from the user's first
22
+ * tap; Android and desktop need no permission (desktop simply never
23
+ * fires devicemotion — use the Ctrl/Cmd+` shortcut there). Set false to
24
+ * keep the keyboard shortcut as the only trigger.
25
+ */
26
+ shakeToOpen?: boolean;
18
27
  /** Options forwarded to the QueryClient constructor. */
19
28
  queryClientOptions?: Record<string, unknown>;
20
29
  /**
@@ -33,6 +42,22 @@ export interface TanstackConfig {
33
42
  * Passed automatically by `createApp()` from the theme config.
34
43
  */
35
44
  tamaguiConfig?: unknown;
45
+ /**
46
+ * Theme COLOR names the devtools panel offers in its accent dropdown
47
+ * (e.g. `["", "blue", "green", "purple"]`; `""` is the base theme).
48
+ * The panel renders the dropdown only when this AND `onThemeColorChange`
49
+ * are both set, and only then does the panel's Randomize action touch the
50
+ * accent — otherwise the picker is inert.
51
+ */
52
+ themeColors?: string[];
53
+ /** The theme color the app currently has applied (`""` for the base). */
54
+ currentThemeColor?: string;
55
+ /**
56
+ * Apply a theme color the user picked. The app owns this because a theme
57
+ * color is app state, not devtools state: the panel changes nothing on its
58
+ * own, it hands the choice back.
59
+ */
60
+ onThemeColorChange?: (color: string) => void;
36
61
  }
37
62
  export interface KeycloakConfig {
38
63
  /** Force-enable or disable. When omitted, reads KEYCLOAK_ENABLED from env config. */
@@ -1 +1 @@
1
- {"version":3,"file":"CreateApp.d.ts","sourceRoot":"","sources":["../../src/app/CreateApp.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"CreateApp.d.ts","sourceRoot":"","sources":["../../src/app/CreateApp.tsx"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"TanstackDevtools.d.ts","sourceRoot":"","sources":["../../src/app/TanstackDevtools.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAsBlD,MAAM,WAAW,qBAAqB;IACpC,cAAc,EAAE,OAAO,GAAG,MAAM,CAAC;IACjC,cAAc,EAAE,cAAc,CAAC;CAChC;AAkED,MAAM,CAAC,OAAO,UAAU,uBAAuB,CAAC,EAC9C,cAAc,EACd,cAAc,GACf,EAAE,qBAAqB,2CA0CvB"}
1
+ {"version":3,"file":"TanstackDevtools.d.ts","sourceRoot":"","sources":["../../src/app/TanstackDevtools.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAoClD,MAAM,WAAW,qBAAqB;IACpC,cAAc,EAAE,OAAO,GAAG,MAAM,CAAC;IACjC,cAAc,EAAE,cAAc,CAAC;CAChC;AAkED,MAAM,CAAC,OAAO,UAAU,uBAAuB,CAAC,EAC9C,cAAc,EACd,cAAc,GACf,EAAE,qBAAqB,2CAiEvB"}
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Keyboard shortcut that toggles the devtools shell (Ctrl+` / Cmd+`).
3
+ * Handled inside `@tanstack/react-devtools`, which skips editable targets
4
+ * (inputs, textareas, contentEditable), so it cannot collide with app
5
+ * typing. Exported so tests pin the binding.
6
+ */
7
+ export declare const devtoolsOpenHotkey: readonly ["CtrlOrMeta", "`"];
8
+ /**
9
+ * Plugin id of the TanStack Devtools shell's internal event client
10
+ * (`devtoolsEventClient` in `@tanstack/devtools-client`). The shell
11
+ * subscribes to `<pluginId>:trigger-toggled` and opens/closes on the
12
+ * payload's `isOpen`. The spec asserts both strings
13
+ * against the installed packages so an upstream rename fails the build
14
+ * instead of silently breaking shake-to-open.
15
+ */
16
+ export declare const devtoolsShellPluginId = "tanstack-devtools-core";
17
+ /** Event suffix the shell listens on for open/close state changes. */
18
+ export declare const devtoolsTriggerToggledEvent = "trigger-toggled";
19
+ /**
20
+ * Open the TanStack Devtools shell programmatically by emitting the same
21
+ * bus event the shell's own (hidden) trigger button uses. Open-only by
22
+ * design: a continued shake must not immediately re-close the panel —
23
+ * closing stays on the close button / Escape / the keyboard shortcut.
24
+ * The event client queues emits until the bus connects, so an early shake
25
+ * racing the shell mount is still delivered.
26
+ */
27
+ export declare function openDevtoolsShell(): void;
28
+ /** Defaults: deliberate shakes peak 15–25 m/s²; walking/bumps stay under ~8. */
29
+ export declare const shakeDefaults: {
30
+ /** m/s² a sample must exceed to count as a shake peak. */
31
+ readonly threshold: 12;
32
+ /** Minimum ms between counted peaks (one swing = one peak, not many samples). */
33
+ readonly minPeakGapMs: 80;
34
+ /** Peaks required inside the rolling window to fire. */
35
+ readonly peakCount: 3;
36
+ /** Rolling window (ms) the peaks must land in. */
37
+ readonly windowMs: 1000;
38
+ /** Dead time (ms) after firing before the detector re-arms. */
39
+ readonly cooldownMs: 2000;
40
+ };
41
+ interface MotionAxes {
42
+ x?: number | null;
43
+ y?: number | null;
44
+ z?: number | null;
45
+ }
46
+ /** Structural subset of DeviceMotionEvent the detector needs (testable in node). */
47
+ export interface MotionEventLike {
48
+ acceleration?: MotionAxes | null;
49
+ accelerationIncludingGravity?: MotionAxes | null;
50
+ }
51
+ /**
52
+ * Acceleration magnitude (m/s²) of a devicemotion sample. Prefers the
53
+ * gravity-excluded reading; falls back to the including-gravity reading
54
+ * minus standard gravity. Returns null when the event carries no data
55
+ * (some desktop browsers fire devicemotion with all-null axes).
56
+ */
57
+ export declare function motionMagnitude(event: MotionEventLike): number | null;
58
+ export interface ShakeDetectorOptions {
59
+ onShake: () => void;
60
+ threshold?: number;
61
+ minPeakGapMs?: number;
62
+ peakCount?: number;
63
+ windowMs?: number;
64
+ cooldownMs?: number;
65
+ }
66
+ export interface ShakeDetector {
67
+ /** Feed a devicemotion sample. `now` is injectable for tests. */
68
+ handleMotion: (event: MotionEventLike, now?: number) => void;
69
+ /** Drop all peaks and cooldown state. */
70
+ reset: () => void;
71
+ }
72
+ /**
73
+ * Debounced shake detector: fires `onShake` when `peakCount` peaks above
74
+ * `threshold` (each at least `minPeakGapMs` apart) land inside a rolling
75
+ * `windowMs`, then goes dead for `cooldownMs` before re-arming.
76
+ */
77
+ export declare function createShakeDetector({ onShake, threshold, minPeakGapMs, peakCount, windowMs, cooldownMs, }: ShakeDetectorOptions): ShakeDetector;
78
+ export type MotionPermissionState = "granted" | "awaiting-gesture" | "requesting" | "denied";
79
+ export interface MotionPermissionFlow {
80
+ readonly state: MotionPermissionState;
81
+ /**
82
+ * Call from a user gesture (pointerup/click). Resolves once the
83
+ * permission settles; no-ops unless state is "awaiting-gesture".
84
+ */
85
+ handleGesture: () => Promise<void>;
86
+ }
87
+ /**
88
+ * Permission state machine. Platforms without
89
+ * `DeviceMotionEvent.requestPermission` (Android, desktop) grant
90
+ * immediately; iOS WebKit waits for a user gesture, asks once, and lands
91
+ * on granted or denied. A denial is terminal for this page load — Safari
92
+ * remembers the answer per site, so later loads resolve silently.
93
+ */
94
+ export declare function createMotionPermissionFlow({ requestPermission, onGranted, onDenied, }: {
95
+ requestPermission?: () => Promise<string>;
96
+ onGranted: () => void;
97
+ onDenied?: () => void;
98
+ }): MotionPermissionFlow;
99
+ /**
100
+ * True only on the platform that gates devicemotion behind a user-gesture
101
+ * grant: iOS 13+ WebKit exposes `DeviceMotionEvent.requestPermission`.
102
+ * Android and desktop return false — they deliver motion events without a
103
+ * prompt, so shake-to-open arms itself there and no trigger UI is needed.
104
+ */
105
+ export declare function isMotionPermissionRequired(): boolean;
106
+ /**
107
+ * The window event an app dispatches from a DELIBERATE user gesture to arm
108
+ * shake-to-open on iOS (e.g. the SHC console's triple-tap on the build
109
+ * number in Settings › Estate). It is a window CustomEvent rather than an
110
+ * imported function on purpose: the trigger lives in a feature package that
111
+ * does not — and must not — depend on this core build. `dispatchEvent` is
112
+ * synchronous, so the `requestPermission()` this fires runs INSIDE the
113
+ * originating gesture's call stack, which is the whole reason iOS accepts
114
+ * it. `armDevtoolsMotion()` dispatches it for callers that CAN import core.
115
+ */
116
+ export declare const devtoolsArmMotionEvent = "mpo:devtools-arm-motion";
117
+ /**
118
+ * The window event core dispatches back once an arm attempt settles, so a
119
+ * trigger UI can report the honest outcome. `detail.granted` is true only
120
+ * when motion is now live.
121
+ */
122
+ export declare const devtoolsMotionArmedEvent = "mpo:devtools-motion-armed";
123
+ /** Test seam: forget the grant and drop attachers between cases. */
124
+ export declare function __resetMotionArming(): void;
125
+ /**
126
+ * Request motion permission from within a user gesture and, on grant,
127
+ * attach every waiting shake listener. Idempotent once granted. Resolves
128
+ * to the settled permission state; a denial is terminal for this page load
129
+ * (Safari remembers it per site). Broadcast the outcome on
130
+ * `devtoolsMotionArmedEvent` so a trigger UI can report it.
131
+ */
132
+ export declare function requestMotionPermissionFromGesture(): Promise<MotionPermissionState>;
133
+ /**
134
+ * Arm shake-to-open programmatically. For callers that CAN import core
135
+ * (the app layer); a feature package instead dispatches
136
+ * `devtoolsArmMotionEvent` directly. Must be called from a user gesture on
137
+ * iOS. No-op on the server.
138
+ */
139
+ export declare function armDevtoolsMotion(): void;
140
+ /**
141
+ * Shake-to-open for the devtools shell. Attaches a `devicemotion` listener
142
+ * and opens the shell on a detected shake. Desktop browsers deliver no
143
+ * devicemotion events, so the listener is inert there — the keyboard
144
+ * shortcut is the desktop path.
145
+ *
146
+ * iOS 13+ gates devicemotion behind a permission prompt that can ONLY be
147
+ * requested from a user gesture, and the first shake cannot self-grant. So
148
+ * on iOS the listener attaches only after the app arms it — by dispatching
149
+ * `devtoolsArmMotionEvent` from a deliberate gesture (the console wires the
150
+ * build-number triple-tap). Android/desktop attach immediately. SSR-safe;
151
+ * every listener is torn down on unmount.
152
+ */
153
+ export declare function useShakeToOpen(enabled: boolean, onShake?: () => void): void;
154
+ export {};
155
+ //# sourceMappingURL=devtoolsTrigger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"devtoolsTrigger.d.ts","sourceRoot":"","sources":["../../src/app/devtoolsTrigger.ts"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,8BAA+B,CAAC;AAM/D;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,2BAA2B,CAAC;AAE9D,sEAAsE;AACtE,eAAO,MAAM,2BAA2B,oBAAoB,CAAC;AAQ7D;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,SAKhC;AAQD,gFAAgF;AAChF,eAAO,MAAM,aAAa;IACxB,0DAA0D;;IAE1D,iFAAiF;;IAEjF,wDAAwD;;IAExD,kDAAkD;;IAElD,+DAA+D;;CAEvD,CAAC;AAEX,UAAU,UAAU;IAClB,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,CAAC,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACnB;AAED,oFAAoF;AACpF,MAAM,WAAW,eAAe;IAC9B,YAAY,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;IACjC,4BAA4B,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;CAClD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,eAAe,GAAG,MAAM,GAAG,IAAI,CAUrE;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,aAAa;IAC5B,iEAAiE;IACjE,YAAY,EAAE,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,yCAAyC;IACzC,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,OAAO,EACP,SAAmC,EACnC,YAAyC,EACzC,SAAmC,EACnC,QAAiC,EACjC,UAAqC,GACtC,EAAE,oBAAoB,GAAG,aAAa,CA2BtC;AAMD,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,kBAAkB,GAAG,YAAY,GAAG,QAAQ,CAAC;AAE7F,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,qBAAqB,CAAC;IACtC;;;OAGG;IACH,aAAa,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,EACzC,iBAAiB,EACjB,SAAS,EACT,QAAQ,GACT,EAAE;IACD,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1C,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;CACvB,GAAG,oBAAoB,CA8BvB;AAcD;;;;;GAKG;AACH,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,4BAA4B,CAAC;AAEhE;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAC;AA4BpE,oEAAoE;AACpE,wBAAgB,mBAAmB,SAGlC;AAED;;;;;;GAMG;AACH,wBAAsB,kCAAkC,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAqBzF;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,IAAI,CAGxC;AAMD;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,GAAE,MAAM,IAAwB,QA+BvF"}