@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 +14 -13
- package/src/app/CreateApp.tsx +25 -0
- package/src/app/TanstackDevtools.tsx +49 -5
- package/src/app/devtoolsTrigger.spec.ts +488 -0
- package/src/app/devtoolsTrigger.ts +385 -0
- package/types/app/CreateApp.d.ts +25 -0
- package/types/app/CreateApp.d.ts.map +1 -1
- package/types/app/TanstackDevtools.d.ts.map +1 -1
- package/types/app/devtoolsTrigger.d.ts +155 -0
- package/types/app/devtoolsTrigger.d.ts.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/core",
|
|
3
|
-
"version": "7.
|
|
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/
|
|
50
|
-
"@multiplatform.one/logger": "7.
|
|
51
|
-
"@multiplatform.one/
|
|
52
|
-
"@multiplatform.one/
|
|
53
|
-
"@multiplatform.one/
|
|
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/
|
|
72
|
-
"@multiplatform.one/
|
|
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.
|
|
89
|
-
"@multiplatform.one/frappe-ui": "^7.
|
|
90
|
-
"@multiplatform.one/keycloak": "^7.
|
|
91
|
-
"@multiplatform.one/web3": "^7.
|
|
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": {
|
package/src/app/CreateApp.tsx
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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/
|
|
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: [
|
|
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
|
+
}
|
package/types/app/CreateApp.d.ts
CHANGED
|
@@ -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":"AAAA,OAAc,EAKZ,KAAK,aAAa,EAClB,KAAK,iBAAiB,EAEvB,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAU5D,MAAM,MAAM,iBAAiB,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAC;AAIjE;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAWtE;AAMD,MAAM,WAAW,cAAc;IAC7B,gFAAgF;IAChF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,wDAAwD;IACxD,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7C;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM;QACpB,OAAO,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;QACrC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC;QACxB,GAAG,EAAE,CAAC,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,KAAK,IAAI,CAAC;KAC/C,CAAC;IACF;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"CreateApp.d.ts","sourceRoot":"","sources":["../../src/app/CreateApp.tsx"],"names":[],"mappings":"AAAA,OAAc,EAKZ,KAAK,aAAa,EAClB,KAAK,iBAAiB,EAEvB,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAU5D,MAAM,MAAM,iBAAiB,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAC;AAIjE;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAWtE;AAMD,MAAM,WAAW,cAAc;IAC7B,gFAAgF;IAChF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,wDAAwD;IACxD,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC7C;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM;QACpB,OAAO,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,CAAC;QACrC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC;QACxB,GAAG,EAAE,CAAC,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,MAAM,KAAK,IAAI,CAAC;KAC/C,CAAC;IACF;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,yEAAyE;IACzE,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9C;AAED,MAAM,WAAW,cAAc;IAC7B,qFAAqF;IACrF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC9B;;qBAEiB;IACjB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,8DAA8D;IAC9D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,mFAAmF;IACnF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE;QACL,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,KAAK,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;QAClD,IAAI,CAAC,EAAE,MAAM,CAAC;KACf,CAAC;CACH;AAED,MAAM,WAAW,aAAa;IAC5B,iFAAiF;IACjF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,kFAAkF;IAClF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iHAAiH;IACjH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAMD,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB;;;;OAIG;IACH,SAAS,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAChC,gFAAgF;IAChF,QAAQ,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC;IACpC,8FAA8F;IAC9F,QAAQ,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC;IACpC,uFAAuF;IACvF,MAAM,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC;IAChC,4FAA4F;IAC5F,IAAI,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD,gDAAgD;IAChD,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,gFAAgF;IAChF,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAQD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,eAAe;;iDAsLW,gBAAgB;;;cA2B5D,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,UAAU,aAAa,CAAC,CAAC,CAAC,KAAG,aAAa,CAAC,CAAC,CAAC;wBAatE,eAAe;EAKzC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TanstackDevtools.d.ts","sourceRoot":"","sources":["../../src/app/TanstackDevtools.tsx"],"names":[],"mappings":"
|
|
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"}
|