@omg-dev/sdk 0.4.29 → 0.4.31

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.
@@ -1,296 +0,0 @@
1
- // @omg-dev/sdk/feedback — gesture triggers for the feedback widget.
2
- //
3
- // Dependency-free. Detects the gestures a user can use to summon the feedback
4
- // panel from anywhere in a running app:
5
- //
6
- // - "shake" device shake (accelerometer; iOS needs a permission grant)
7
- // - "swipe-up" swipe IN from the bottom edge
8
- // - "swipe-down" swipe IN from the top edge
9
- // - "swipe-left" swipe IN from the right edge
10
- // - "swipe-right" swipe IN from the left edge
11
- // - "two-finger-press" press-and-hold with two fingers (no OS-gesture conflict)
12
- //
13
- // Edge swipes overlap with OS gestures on some platforms (iOS Safari: left/right
14
- // edge = back/forward, bottom = home, top = control center). The default is the
15
- // conflict-free, permission-free "two-finger-press" — the developer opts into
16
- // edge swipes and "shake" explicitly. Every detector is a no-op on platforms
17
- // that lack the relevant input (no touch / no motion), so enabling any is safe.
18
- //
19
- // "shake" is deliberately NOT a default: iOS gates the accelerometer behind a
20
- // DeviceMotionEvent permission it refuses to persist across PWA launches, so a
21
- // shake default forces users to re-grant motion access every session. The
22
- // always-anchored feedback button (VibesFeedback `showButton`, on by default)
23
- // is the reliable cross-platform summon; shake is an opt-in bonus.
24
-
25
- import { useEffect, useRef, useState } from "react"
26
-
27
- export type FeedbackGesture =
28
- | "shake"
29
- | "swipe-up"
30
- | "swipe-down"
31
- | "swipe-left"
32
- | "swipe-right"
33
- | "two-finger-press"
34
-
35
- /** Default gestures when the host doesn't specify any. Permission-free and
36
- * OS-gesture-safe — notably excludes "shake" (see the note above). */
37
- export const DEFAULT_GESTURES: FeedbackGesture[] = ["two-finger-press"]
38
-
39
- export type MotionPermission = "granted" | "denied" | "prompt" | "unsupported"
40
-
41
- export interface GestureOptions {
42
- /** Which gestures arm the trigger. Default: ["shake", "two-finger-press"]. */
43
- gestures?: FeedbackGesture[]
44
- /** Distance (px) from an edge a touch must START within to count as an edge swipe. Default 28. */
45
- edgeSize?: number
46
- /** Travel (px) a swipe must cover to fire. Default 90. */
47
- swipeThreshold?: number
48
- /** Max duration (ms) for a swipe — longer = a scroll/drag, ignored. Default 600. */
49
- swipeMaxDuration?: number
50
- /** Accelerometer delta that counts as a shake "jolt". Default 24. */
51
- shakeThreshold?: number
52
- /** Min hold (ms) for the two-finger press. Default 450. */
53
- pressDuration?: number
54
- /** Quiet period (ms) after a trigger before another can fire. Default 1200. */
55
- cooldown?: number
56
- }
57
-
58
- const DEFAULTS: Required<Omit<GestureOptions, "gestures">> = {
59
- edgeSize: 28,
60
- swipeThreshold: 90,
61
- swipeMaxDuration: 600,
62
- shakeThreshold: 24,
63
- pressDuration: 450,
64
- cooldown: 1200,
65
- }
66
-
67
- function hasWindow(): boolean {
68
- return typeof window !== "undefined" && typeof document !== "undefined"
69
- }
70
-
71
- // DeviceMotionEvent.requestPermission exists only on iOS 13+ Safari.
72
- type MotionCtor = {
73
- requestPermission?: () => Promise<"granted" | "denied">
74
- }
75
-
76
- export function motionPermissionState(): MotionPermission {
77
- if (!hasWindow() || typeof (window as unknown as { DeviceMotionEvent?: unknown }).DeviceMotionEvent === "undefined") {
78
- return "unsupported"
79
- }
80
- const ctor = (window as unknown as { DeviceMotionEvent: MotionCtor }).DeviceMotionEvent
81
- // If the gated API exists we must ask; otherwise motion is freely available.
82
- return typeof ctor.requestPermission === "function" ? "prompt" : "granted"
83
- }
84
-
85
- /**
86
- * Ask for accelerometer access (iOS 13+). MUST be called from inside a user
87
- * gesture handler (tap), or iOS rejects it. No-op elsewhere (returns true).
88
- */
89
- export async function requestMotionPermission(): Promise<boolean> {
90
- const state = motionPermissionState()
91
- if (state === "granted") return true
92
- if (state === "unsupported") return false
93
- const ctor = (window as unknown as { DeviceMotionEvent: MotionCtor }).DeviceMotionEvent
94
- try {
95
- const res = await ctor.requestPermission!()
96
- return res === "granted"
97
- } catch {
98
- return false
99
- }
100
- }
101
-
102
- /**
103
- * Attach gesture listeners to the document. Returns a teardown function.
104
- * `onTrigger` fires (debounced by `cooldown`) whenever any armed gesture
105
- * completes. Pure DOM — usable without React.
106
- */
107
- export function attachGestureListeners(onTrigger: () => void, options: GestureOptions = {}): () => void {
108
- if (!hasWindow()) return () => {}
109
-
110
- const opts = { ...DEFAULTS, ...options }
111
- const gestures = new Set<FeedbackGesture>(options.gestures ?? DEFAULT_GESTURES)
112
- const cleanups: Array<() => void> = []
113
-
114
- let lastTrigger = 0
115
- function fire() {
116
- const now = Date.now()
117
- if (now - lastTrigger < opts.cooldown) return
118
- lastTrigger = now
119
- // A short haptic confirms the gesture registered (mobile only).
120
- try {
121
- ;(navigator as unknown as { vibrate?: (p: number) => void }).vibrate?.(12)
122
- } catch {
123
- /* no haptics */
124
- }
125
- onTrigger()
126
- }
127
-
128
- // ── edge swipes ──────────────────────────────────────────────────────────
129
- const wantsSwipe =
130
- gestures.has("swipe-up") || gestures.has("swipe-down") || gestures.has("swipe-left") || gestures.has("swipe-right")
131
- if (wantsSwipe) {
132
- let sx = 0
133
- let sy = 0
134
- let st = 0
135
- let fromEdge: "top" | "bottom" | "left" | "right" | null = null
136
-
137
- const onStart = (e: TouchEvent) => {
138
- if (e.touches.length !== 1) {
139
- fromEdge = null
140
- return
141
- }
142
- const t = e.touches[0]
143
- sx = t.clientX
144
- sy = t.clientY
145
- st = Date.now()
146
- const w = window.innerWidth
147
- const h = window.innerHeight
148
- fromEdge =
149
- gestures.has("swipe-down") && sy <= opts.edgeSize
150
- ? "top"
151
- : gestures.has("swipe-up") && sy >= h - opts.edgeSize
152
- ? "bottom"
153
- : gestures.has("swipe-right") && sx <= opts.edgeSize
154
- ? "left"
155
- : gestures.has("swipe-left") && sx >= w - opts.edgeSize
156
- ? "right"
157
- : null
158
- }
159
- const onEnd = (e: TouchEvent) => {
160
- if (!fromEdge) return
161
- const edge = fromEdge
162
- fromEdge = null
163
- const t = e.changedTouches[0]
164
- if (!t) return
165
- const dx = t.clientX - sx
166
- const dy = t.clientY - sy
167
- if (Date.now() - st > opts.swipeMaxDuration) return
168
- const passed =
169
- (edge === "bottom" && -dy >= opts.swipeThreshold && Math.abs(dy) > Math.abs(dx)) ||
170
- (edge === "top" && dy >= opts.swipeThreshold && Math.abs(dy) > Math.abs(dx)) ||
171
- (edge === "right" && -dx >= opts.swipeThreshold && Math.abs(dx) > Math.abs(dy)) ||
172
- (edge === "left" && dx >= opts.swipeThreshold && Math.abs(dx) > Math.abs(dy))
173
- if (passed) fire()
174
- }
175
- document.addEventListener("touchstart", onStart, { passive: true })
176
- document.addEventListener("touchend", onEnd, { passive: true })
177
- cleanups.push(() => {
178
- document.removeEventListener("touchstart", onStart)
179
- document.removeEventListener("touchend", onEnd)
180
- })
181
- }
182
-
183
- // ── two-finger press-and-hold ──────────────────────────────────────────────
184
- if (gestures.has("two-finger-press")) {
185
- let timer: ReturnType<typeof setTimeout> | null = null
186
- const clear = () => {
187
- if (timer) {
188
- clearTimeout(timer)
189
- timer = null
190
- }
191
- }
192
- const onStart = (e: TouchEvent) => {
193
- if (e.touches.length === 2) {
194
- clear()
195
- timer = setTimeout(fire, opts.pressDuration)
196
- } else {
197
- clear()
198
- }
199
- }
200
- document.addEventListener("touchstart", onStart, { passive: true })
201
- document.addEventListener("touchend", clear, { passive: true })
202
- document.addEventListener("touchmove", clear, { passive: true })
203
- document.addEventListener("touchcancel", clear, { passive: true })
204
- cleanups.push(() => {
205
- clear()
206
- document.removeEventListener("touchstart", onStart)
207
- document.removeEventListener("touchend", clear)
208
- document.removeEventListener("touchmove", clear)
209
- document.removeEventListener("touchcancel", clear)
210
- })
211
- }
212
-
213
- // ── shake ──────────────────────────────────────────────────────────────────
214
- if (gestures.has("shake")) {
215
- let lx = 0
216
- let ly = 0
217
- let lz = 0
218
- let seeded = false
219
- let jolts = 0
220
- let windowStart = 0
221
- const onMotion = (e: DeviceMotionEvent) => {
222
- const a = e.accelerationIncludingGravity
223
- if (!a || a.x == null || a.y == null || a.z == null) return
224
- if (!seeded) {
225
- lx = a.x
226
- ly = a.y
227
- lz = a.z
228
- seeded = true
229
- return
230
- }
231
- const delta = Math.abs(a.x - lx) + Math.abs(a.y - ly) + Math.abs(a.z - lz)
232
- lx = a.x
233
- ly = a.y
234
- lz = a.z
235
- if (delta < opts.shakeThreshold) return
236
- const now = Date.now()
237
- if (now - windowStart > 1000) {
238
- windowStart = now
239
- jolts = 0
240
- }
241
- // A real shake is several jolts in quick succession, not one bump.
242
- if (++jolts >= 3) {
243
- jolts = 0
244
- fire()
245
- }
246
- }
247
- window.addEventListener("devicemotion", onMotion)
248
- cleanups.push(() => window.removeEventListener("devicemotion", onMotion))
249
- }
250
-
251
- return () => {
252
- for (const c of cleanups) c()
253
- }
254
- }
255
-
256
- /**
257
- * React hook: arm the feedback gestures and call `onTrigger` when one fires.
258
- * Returns the motion-permission state + a primer to request it (iOS).
259
- */
260
- export function useFeedbackGesture(
261
- onTrigger: () => void,
262
- options: GestureOptions & { enabled?: boolean } = {},
263
- ): { motionPermission: MotionPermission; requestMotionPermission: () => Promise<boolean> } {
264
- const cb = useRef(onTrigger)
265
- cb.current = onTrigger
266
- const [motionPermission, setMotionPermission] = useState<MotionPermission>(() => motionPermissionState())
267
-
268
- const { enabled = true } = options
269
- // Re-arm when the gesture SET or tuning changes — not on every render.
270
- const sig = JSON.stringify({
271
- g: options.gestures ?? DEFAULT_GESTURES,
272
- e: options.edgeSize,
273
- s: options.swipeThreshold,
274
- d: options.swipeMaxDuration,
275
- t: options.shakeThreshold,
276
- p: options.pressDuration,
277
- c: options.cooldown,
278
- enabled,
279
- })
280
-
281
- useEffect(() => {
282
- if (!enabled) return
283
- const detach = attachGestureListeners(() => cb.current(), options)
284
- return detach
285
- // eslint-disable-next-line react-hooks/exhaustive-deps
286
- }, [sig])
287
-
288
- return {
289
- motionPermission,
290
- requestMotionPermission: async () => {
291
- const ok = await requestMotionPermission()
292
- setMotionPermission(ok ? "granted" : "denied")
293
- return ok
294
- },
295
- }
296
- }
@@ -1,18 +0,0 @@
1
- // @omg-dev/sdk/feedback — gesture-summoned feedback widget + trace capture.
2
- //
3
- // Re-exported from the SDK root (`@omg-dev/sdk`) as well, because the publish
4
- // workflow rewrites `exports` to only "." — subpath imports like
5
- // `@omg-dev/sdk/feedback` don't resolve in production deploy builds.
6
-
7
- export { VibesFeedback, type VibesFeedbackProps, type FeedbackReport } from "./VibesFeedback"
8
- export {
9
- useFeedbackGesture,
10
- attachGestureListeners,
11
- requestMotionPermission,
12
- motionPermissionState,
13
- type FeedbackGesture,
14
- type GestureOptions,
15
- type MotionPermission,
16
- } from "./gestures"
17
- export { installTrace, getTrace, clearTrace, type Breadcrumb, type BreadcrumbKind, type TraceOptions } from "./trace"
18
- export { captureScreenshot, type ScreenshotOptions } from "./screenshot"
@@ -1,37 +0,0 @@
1
- // @omg-dev/sdk/feedback — client-side screenshot capture.
2
- //
3
- // Lazily imports `modern-screenshot` only when a capture is requested, so it's
4
- // a separate chunk and never weighs down the app's main bundle. Rasterizes the
5
- // live DOM (what the user is looking at) — the platform's server-side
6
- // thumbnailer re-navigates the URL and loses the user's current state, so it
7
- // can't be used for "capture the app in place".
8
- //
9
- // The widget's own nodes are tagged `data-vibes-feedback` and filtered out, so
10
- // the panel/backdrop/button never appear in the shot.
11
-
12
- export interface ScreenshotOptions {
13
- /** Device-pixel scale, capped. Default min(devicePixelRatio, 2). */
14
- scale?: number
15
- /** Element to capture. Default document.body. */
16
- target?: HTMLElement
17
- }
18
-
19
- export async function captureScreenshot(options: ScreenshotOptions = {}): Promise<string | null> {
20
- if (typeof document === "undefined" || typeof window === "undefined") return null
21
- const target = options.target ?? document.body
22
- if (!target) return null
23
- try {
24
- // Dynamic import → lazy chunk. Wrapped so a missing/older bundle that
25
- // lacks the dep degrades to "no screenshot" instead of throwing.
26
- const mod = (await import("modern-screenshot")) as {
27
- domToPng: (node: HTMLElement, opts?: Record<string, unknown>) => Promise<string>
28
- }
29
- return await mod.domToPng(target, {
30
- scale: options.scale ?? Math.min(window.devicePixelRatio || 1, 2),
31
- backgroundColor: getComputedStyle(document.body).backgroundColor || "#ffffff",
32
- filter: (node: Node) => !(node instanceof HTMLElement && node.hasAttribute("data-vibes-feedback")),
33
- })
34
- } catch {
35
- return null
36
- }
37
- }
@@ -1,166 +0,0 @@
1
- // @omg-dev/sdk/feedback — client trace ring buffer.
2
- //
3
- // Installs lightweight, low-overhead breadcrumb collectors so a feedback report
4
- // can carry the recent runtime trace: console errors/warnings, uncaught errors,
5
- // unhandled rejections, and network (fetch) failures + slow calls. Bounded ring
6
- // buffer (default 50) so memory stays flat. install() is idempotent and returns
7
- // a teardown; getTrace() snapshots the current buffer for a report.
8
- //
9
- // This mirrors the platform's existing vite-plugin error reporter (which
10
- // postMessages `vibes:runtime-error` to the dashboard) but runs entirely inside
11
- // the app so it works in published deploys too, not just the dev preview.
12
-
13
- export type BreadcrumbKind = "console" | "error" | "rejection" | "network" | "nav"
14
-
15
- export interface Breadcrumb {
16
- at: number
17
- kind: BreadcrumbKind
18
- /** "error" | "warn" for console; HTTP method for network; etc. */
19
- level?: string
20
- message: string
21
- /** Extra structured detail (url, status, durationMs, stack…). */
22
- data?: Record<string, unknown>
23
- }
24
-
25
- interface Installed {
26
- buffer: Breadcrumb[]
27
- teardown: () => void
28
- max: number
29
- }
30
-
31
- let installed: Installed | null = null
32
-
33
- function push(b: Breadcrumb) {
34
- if (!installed) return
35
- installed.buffer.push(b)
36
- if (installed.buffer.length > installed.max) installed.buffer.shift()
37
- }
38
-
39
- function safeString(v: unknown): string {
40
- if (typeof v === "string") return v
41
- if (v instanceof Error) return v.message
42
- try {
43
- return JSON.stringify(v)
44
- } catch {
45
- return String(v)
46
- }
47
- }
48
-
49
- export interface TraceOptions {
50
- /** Max breadcrumbs retained. Default 50. */
51
- max?: number
52
- /** Patch console.error/warn. Default true. */
53
- console?: boolean
54
- /** Capture window error + unhandledrejection. Default true. */
55
- errors?: boolean
56
- /** Wrap fetch to record failures + slow (>2s) calls. Default true. */
57
- network?: boolean
58
- }
59
-
60
- /** Begin collecting breadcrumbs. Idempotent — a second call is a no-op. */
61
- export function installTrace(options: TraceOptions = {}): () => void {
62
- if (installed) return installed.teardown
63
- if (typeof window === "undefined") return () => {}
64
-
65
- const max = options.max ?? 50
66
- const buffer: Breadcrumb[] = []
67
- const cleanups: Array<() => void> = []
68
- installed = { buffer, teardown: () => {}, max }
69
-
70
- // ── console.error / console.warn ──────────────────────────────────────────
71
- if (options.console !== false && typeof console !== "undefined") {
72
- for (const level of ["error", "warn"] as const) {
73
- const orig = console[level]
74
- if (typeof orig !== "function") continue
75
- console[level] = (...args: unknown[]) => {
76
- push({ at: Date.now(), kind: "console", level, message: args.map(safeString).join(" ").slice(0, 1000) })
77
- return (orig as (...a: unknown[]) => void).apply(console, args)
78
- }
79
- cleanups.push(() => {
80
- console[level] = orig as typeof console.error
81
- })
82
- }
83
- }
84
-
85
- // ── uncaught errors + rejections ──────────────────────────────────────────
86
- if (options.errors !== false) {
87
- const onError = (e: ErrorEvent) => {
88
- push({
89
- at: Date.now(),
90
- kind: "error",
91
- message: e.message || "Uncaught error",
92
- data: { source: e.filename, line: e.lineno, col: e.colno, stack: e.error?.stack?.slice(0, 2000) },
93
- })
94
- }
95
- const onRejection = (e: PromiseRejectionEvent) => {
96
- const r = e.reason as unknown
97
- push({
98
- at: Date.now(),
99
- kind: "rejection",
100
- message: safeString(r).slice(0, 1000),
101
- data: { stack: (r as Error)?.stack?.slice(0, 2000) },
102
- })
103
- }
104
- window.addEventListener("error", onError)
105
- window.addEventListener("unhandledrejection", onRejection)
106
- cleanups.push(() => {
107
- window.removeEventListener("error", onError)
108
- window.removeEventListener("unhandledrejection", onRejection)
109
- })
110
- }
111
-
112
- // ── fetch failures + slow calls ───────────────────────────────────────────
113
- if (options.network !== false && typeof window.fetch === "function") {
114
- const origFetch = window.fetch.bind(window)
115
- window.fetch = async (...args: Parameters<typeof fetch>) => {
116
- const started = Date.now()
117
- const url = typeof args[0] === "string" ? args[0] : (args[0] as Request)?.url ?? String(args[0])
118
- const method = (args[1]?.method ?? (args[0] as Request)?.method ?? "GET").toUpperCase()
119
- try {
120
- const res = await origFetch(...args)
121
- const durationMs = Date.now() - started
122
- // Only breadcrumb the interesting cases — errors and slow calls — so we
123
- // don't drown the buffer in routine 200s.
124
- if (!res.ok || durationMs > 2000) {
125
- push({
126
- at: started,
127
- kind: "network",
128
- level: method,
129
- message: `${res.status} ${method} ${url}`.slice(0, 500),
130
- data: { status: res.status, durationMs },
131
- })
132
- }
133
- return res
134
- } catch (err) {
135
- push({
136
- at: started,
137
- kind: "network",
138
- level: method,
139
- message: `FAILED ${method} ${url}`.slice(0, 500),
140
- data: { error: safeString(err), durationMs: Date.now() - started },
141
- })
142
- throw err
143
- }
144
- }
145
- cleanups.push(() => {
146
- window.fetch = origFetch
147
- })
148
- }
149
-
150
- const teardown = () => {
151
- for (const c of cleanups) c()
152
- installed = null
153
- }
154
- installed.teardown = teardown
155
- return teardown
156
- }
157
-
158
- /** Snapshot the current breadcrumbs (most-recent last). Empty if not installed. */
159
- export function getTrace(): Breadcrumb[] {
160
- return installed ? [...installed.buffer] : []
161
- }
162
-
163
- /** Clear the buffer without uninstalling (e.g. after a report is sent). */
164
- export function clearTrace(): void {
165
- if (installed) installed.buffer.length = 0
166
- }