lib
PURPOSE
Thin wrappers around platform/native APIs (Capacitor). Each concern that requires a native shell — platform detection, haptics, and eventually in-app purchases or tracking transparency — gets exactly one canonical entry-point module here. The rest of the codebase imports from @/lib/* and never imports Capacitor packages directly. This keeps native-vs-web branching in one place, keeps Capacitor types out of business code, and keeps native plugin modules out of the web bundle’s eager graph via dynamic-import-on-call.
OWNS
Platform— the single source of truth for native/web detection. Frozen (as const) bag of four predicate functions (isNative,isIOS,isAndroid,isWeb) over@capacitor/core’sCapacitor.isNativePlatform()andCapacitor.getPlatform(). Every other module that branches on platform calls these predicates instead of querying Capacitor itself.hapticImpact(style)andhapticNotification(type)— the only sanctioned haptic call sites. Both areasync, both early-return on web, both wrap a dynamicimport('@capacitor/haptics')intry { ... } catch { /* silent */ }. The Capacitor enum (ImpactStyle,NotificationType) is indexed from a string-literal union argument so callers never touch Capacitor types.capacitor-stubs.d.ts— TypeScript ambient module declarations for@capacitor/core,@capacitor/haptics,@revenuecat/purchases-capacitor, and@capgo/capacitor-app-tracking-transparency. These stubs lettscpass on web builds where the native SDKs are not installed; they are erased at compile time and never reach the runtime bundle.- The native-vs-web gating contract: every wrapper in this folder first checks
Platform.isNative(), then dynamically imports the Capacitor plugin, then catches any failure silently. New wrappers (purchases, tracking transparency, status bar, keyboard, etc.) must follow the same shape.
READS FROM
@capacitor/core— onlyplatform.tsimports this statically.Capacitor.isNativePlatform()andCapacitor.getPlatform()are the underlying detection primitives.@capacitor/haptics— onlyhaptics.tsimports this, and only via dynamicimport()inside aPlatform.isNative()guard../platform(Platform.isNative()) —haptics.ts’s early-exit gate. Any future native wrapper in this folder consumes the same predicate.
PUSHES TO
- Native OS haptic engine via
Haptics.impact({ style })andHaptics.notification({ type })(haptics module only). - Nothing else. No store mutations, no signal emissions, no
logDiagcalls, no console output.
DOES NOT
- Statically import any
@capacitor/*plugin module other than@capacitor/core. All plugins are loaded lazily via dynamicimport()so the web bundle never pulls them. - Memoize platform detection. Each
Platform.is*()call re-queries Capacitor — the runtime cost is negligible and the call-time semantics defeat ordering bugs during cold boot when some Capacitor builds populate platform state asynchronously. - Re-export the
Capacitorglobal or any Capacitor enum / type. Consumers cannot reach through these wrappers to the underlying plugin API; they get string-literal unions and boolean predicates only. - Log on failure. Haptics swallow errors silently because they are non-essential UX and the OS-boundary integration is the documented exception zone to the “crash on bad data” rule.
- Expose
getPlatform()as a raw'ios' | 'android' | 'web'accessor. Code that needs the raw string must take a direct Capacitor dependency (and thereby break the chokepoint). - Feature-detect via UA sniffing,
navigator.standalone, orwindow.matchMedia. All branching defers to Capacitor’s own detection. - Throttle, queue, debounce, or batch haptic calls. Each call hits the OS independently.
- Check user / settings opt-in for haptics. Callers must gate that themselves before invoking.
- Distinguish tablet vs phone, check iOS version, or check Android API level. The predicate surface is intentionally coarse.
- Provide capability detection (is-haptics-available, is-IAP-available, push-permission-state). Today those queries are scattered across plugin call sites; they would naturally collapse into a
Capabilitiesbag here if the duplication grew.
Signals
None. The lib layer fires no signals and watches none. Platform predicates and haptic functions are pure side-effect-or-detection helpers.
Entry points
Platform.isNative(),Platform.isIOS(),Platform.isAndroid(),Platform.isWeb()— named export fromsrc/lib/platform.ts. Each is invoked as a function (parentheses required).hapticImpact(style?: 'Heavy' | 'Medium' | 'Light'): Promise<void>— default style is'Medium'. Awaiting is optional; resolves once the OS call returns or immediately on web.hapticNotification(type: 'Success' | 'Warning' | 'Error'): Promise<void>— same async-but-fire-and-forget shape.- Ambient module declarations in
capacitor-stubs.d.ts— picked up automatically by the TypeScript compiler fromtsconfig.json’s include glob. No explicit import is needed.
Pattern notes
- One canonical entry point per concern. Each native concern gets exactly one module in this folder. Two haptic helpers in
haptics.ts, four platform predicates inplatform.ts. No alternate paths to the same OS API anywhere in the codebase. - Chokepoint architecture.
platform.tsis the only sanctioned@capacitor/coreimporter outside Capacitor plugin wrappers in this folder.haptics.tsis the only sanctioned@capacitor/hapticsimporter anywhere. New native concerns get a new file here, not a direct Capacitor import in the call site. - Dynamic import for plugin modules. Plugin code (
@capacitor/haptics) is imported viaawait import(...)inside thePlatform.isNative()branch, so Vite’s web bundle never includes it in the eager graph.@capacitor/coreis statically imported because the detection primitives are needed synchronously during boot. - Native-only guard + silent try/catch. Every native wrapper opens with
if (!Platform.isNative()) return;and wraps the plugin call intry { ... } catch { /* silent */ }. Haptics are non-essential UX, so a broken plugin module degrades to no-op rather than a crash — this is the OS-boundary exception zone per CLAUDE.md. - String unions out, Capacitor enums in. Public surfaces accept string-literal types (
'Heavy' | 'Medium' | 'Light','Success' | 'Warning' | 'Error') and index Capacitor’s runtime enum (ImpactStyle[style],NotificationType[type]) internally. Callers never see Capacitor types. - Stubs not packages.
capacitor-stubs.d.tslets web-only builds compile without installing the native SDKs. The stubs declare the minimum module shape each plugin exposes — just enough fortscto resolve imports. Native builds (iOS / Android shells) install the real packages and the stubs are shadowed by the actual.d.tsfiles. - Predicates as methods, not properties.
Platform.isNative()is a function call, not a computed property. This defers the Capacitor query until call time, which matters because some Capacitor builds populate platform state asynchronously on cold boot. as constfreeze. ThePlatformbag is assertedas constto narrow literal types and prevent runtime monkey-patching of the predicates (defense against test code or stray plugins reassigning the functions).
EXTRACT-CANDIDATE
The two existing wrappers share an identical “native-only guard + dynamic-import + silent try/catch” shape. If a third Capacitor wrapper lands (status bar, screen orientation, keyboard, splash screen, in-app purchases via @revenuecat/purchases-capacitor, tracking transparency via @capgo/capacitor-app-tracking-transparency), the pattern is worth factoring into a withNativeCapacitor<T>(loader, call) helper here. The stubs file already pre-declares the IAP and ATT modules, so they are next in line. Until the third wrapper exists, three lines duplicated per file is fine — extraction follows rule-of-three. A Capabilities predicate bag mirroring Platform’s shape (is-haptics-available, is-IAP-available, push-permission-state) would be a sibling extraction once the same questions get asked from more than one call site.