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’s Capacitor.isNativePlatform() and Capacitor.getPlatform(). Every other module that branches on platform calls these predicates instead of querying Capacitor itself.
  • hapticImpact(style) and hapticNotification(type) — the only sanctioned haptic call sites. Both are async, both early-return on web, both wrap a dynamic import('@capacitor/haptics') in try { ... } 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 let tsc pass 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 — only platform.ts imports this statically. Capacitor.isNativePlatform() and Capacitor.getPlatform() are the underlying detection primitives.
  • @capacitor/haptics — only haptics.ts imports this, and only via dynamic import() inside a Platform.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 }) and Haptics.notification({ type }) (haptics module only).
  • Nothing else. No store mutations, no signal emissions, no logDiag calls, no console output.

DOES NOT

  • Statically import any @capacitor/* plugin module other than @capacitor/core. All plugins are loaded lazily via dynamic import() 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 Capacitor global 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, or window.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 Capabilities bag 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 from src/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 from tsconfig.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 in platform.ts. No alternate paths to the same OS API anywhere in the codebase.
  • Chokepoint architecture. platform.ts is the only sanctioned @capacitor/core importer outside Capacitor plugin wrappers in this folder. haptics.ts is the only sanctioned @capacitor/haptics importer 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 via await import(...) inside the Platform.isNative() branch, so Vite’s web bundle never includes it in the eager graph. @capacitor/core is 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 in try { ... } 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.ts lets web-only builds compile without installing the native SDKs. The stubs declare the minimum module shape each plugin exposes — just enough for tsc to resolve imports. Native builds (iOS / Android shells) install the real packages and the stubs are shadowed by the actual .d.ts files.
  • 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 const freeze. The Platform bag is asserted as const to 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.

2 items under this folder.