Ionic / Capacitor Conventions
An Ionic app is an Angular app in a native (Capacitor) shell: the framework rules live in angular-conventions and the language baseline in typescript - load both. This skill is the Ionic/Capacitor-specific layer of house policy. In-app navigation and the page lifecycle are owned here in references/navigation-and-lifecycle.md; broader Ionic UI mechanics (component APIs, theming) are fetched live via context7 or the Ionic docs, not vendored. Per-plugin install/config is fetched live (context7 or the plugin's README); the durable plugin-sourcing and typed-service-wrapping guidance is here in this skill. Cutting a release - the build, signing, store submission, OTA, and release CI - is capacitor-release. Security-hardening the native surface - Keychain/Keystore secret storage, permission least-privilege, cleartext and WebView lockdown, deep-link input trust - is ionic-security. Version floors, the per-major deltas that bite, and the Ionic + Capacitor upgrade paths live in references/versions.md.
Components and structure
- Standalone components + signals, OnPush, new control flow - same as
angular-conventions. Ionic components (IonContent, IonList, ...) are standalone imports, not a shared module.
- Theme through Ionic CSS variables and
color / mode, not hardcoded colors; keep design tokens in one place. Respect the system light/dark setting.
- Handle safe-area / edge-to-edge insets with the CSS
env(safe-area-inset-*) variables (Ionic's --ion-safe-area-*), never fixed padding - Capacitor 8 draws content under the status and navigation bars by default (it dropped adjustMarginsForEdgeToEdge for a System Bars core plugin plus these CSS variables), so pad it back with the insets; the System Bars plugin API is fetched live.
- Dark mode is a palette you opt into, not per-component overrides: import Ionic's dark palette and choose the strategy - follow the OS (system) or an app toggle (the ion-palette-dark class on the root). Theme off the palette's CSS variables; never hand-roll dark colours per component. The v8 import files and the step-token split are in
references/versions.md.
- Respect the OS accessibility settings: Ionic scales type to the device Dynamic Type / font-size setting by default (the
--ion-dynamic-font token) - size with relative units and check large-text layouts, never fixed px that clips. Keep touch targets >= 44px and give every interactive control an accessible name.
- Keep page components thin: data + state in services/stores, presentation in the page.
- Import Ionic UI components and
provideIonicAngular() from the standalone entry point the installed Ionic major documents - @ionic/angular/standalone on Ionic 8, where the bare @ionic/angular barrel pulls in lazy-loaded code that defeats tree-shaking; on Ionic 9 standalone IS the default @ionic/angular entry and the lazy barrel moved to @ionic/angular/lazy. Resolve the path via context7 against the workspace's installed @ionic/angular major before the first import, never from recall.
Form controls - the modern syntax
- Label and validation live on the control, not slotted into
IonItem: IonInput / IonTextarea / IonSelect carry label, labelPlacement, fill (outline / solid), helperText, errorText, and counter directly. Ionic 8 removed the legacy IonItem-wrapped form pattern and the legacy property - never author it or paste it from an old sample.
- The control's
label (or an [aria-label] when it is visually labelled elsewhere) IS its accessible name - a field with neither fails the a11y gate. Build forms with typed reactive FormGroups and surface validation through one shared errorText path, not a per-field @if error wall. That holds even where angular-conventions prefers Signal Forms (v21+): Ionic's controls are documented and tested against the reactive-forms path, so Signal Forms waits on Ionic surfaces until Ionic documents support for the installed major - check the Ionic docs via context7 before assuming, never recall.
Overlays - modal, popover, toast, alert, action-sheet, loading
- Prefer the inline component with
[isOpen] bound to a signal and (didDismiss) handled over the imperative *Controller - overlay state stays in the component and tears down cleanly. Reach for the controller only for a genuinely fire-and-forget prompt.
- Always read the dismissal: handle the backdrop tap, the hardware back, and the returned
role on didDismiss - an overlay whose result you never read is a dropped user decision. Per-overlay component options are fetched live.
Change detection and zoneless
- OnPush everywhere except the shell: never put OnPush on a component that hosts
IonRouterOutlet or IonNav. It stops lifecycle hooks such as ngOnInit from firing and breaks async rendering (Ionic's own docs). Keep those shell components eagerly checked - ChangeDetectionStrategy.Default, renamed Eager in Angular 22, where OnPush became the framework default so the shell now opts out explicitly; apply OnPush only to leaf pages and presentational components.
- Zoneless is gated by the Ionic major, not the Angular one. Ionic 8 keeps Zone.js as a peer dependency and is not zoneless-compatible - treat zoneless as unsupported there whatever Angular runs underneath, and keep
zone.js in the polyfills; signals are still fine in your layer, they just don't make Ionic's components zoneless. Ionic 9 ships official zoneless support (Angular 21+ defaults to it): a plain field set from an async callback no longer repaints on its own, so state flows through signals (or markForCheck()) - which the signals-first rules above already satisfy. Check the installed major before deciding; the 8 -> 9 delta is in references/versions.md.
Navigation
- Route with the Angular router inside an
IonRouterOutlet; lazy-load every feature route via loadComponent / loadChildren. Tabs use IonTabs with their own outlet.
- Don't mix Ionic's imperative nav controllers with the Angular router in one app - pick the router and stay with it.
- Don't add
withViewTransitions() to the router: IonRouterOutlet owns the page-stack transitions, and the two animation systems fight - double or broken transitions.
Ionic page lifecycle
- Ionic caches pages in the DOM, so
ngOnInit / ngOnDestroy fire only on create/pop, not on every revisit - route refresh-on-entry work onto ionViewWillEnter, deferred heavy work onto ionViewDidEnter. The full hook schedule and which hook owns which work are in references/navigation-and-lifecycle.md.
- Control navigation with Angular route guards (
CanActivate / CanDeactivate) - they replaced the old ionViewCanEnter / ionViewCanLeave. Guards yes, route resolvers no for refresh-on-entry data: a cached page's revisit re-activates nothing, so a resolver never re-runs - that data belongs on ionViewWillEnter.
Large lists
- Ionic's own virtual-scroll component was removed in v7 - for long lists use Angular CDK virtual scroll (
CdkVirtualScrollViewport with *cdkVirtualFor) inside IonContent: set [scrollY]="false" on the IonContent and add the ion-content-scroll-host class to the viewport so Ionic's pull-to-refresh and infinite scroll keep working. CDK handles fixed-height rows well; variable-height rows can jank.
Platform detection - pick the right check for the question
Three different questions, three different calls - don't conflate them:
- 'Is there a native bridge at all?' ->
Capacitor.isNativePlatform() (true on iOS and Android, false in a browser / PWA). This is the gate for any code that calls a native plugin path.
- 'Which OS?' ->
Capacitor.getPlatform() returns 'ios' | 'android' | 'web'. Branch on it only for genuinely platform-specific behavior (a status-bar inset, an iOS-only API), never as a substitute for the native check above.
- 'What can the app do right now?' -> Ionic's
Platform service: platform.is('ios' | 'mobile' | 'pwa' | 'desktop' | 'capacitor') plus platform.ready(). Prefer Platform inside Angular components because it injects cleanly and is mockable in tests; reserve the static Capacitor.* calls for plain functions and services with no injection context.
- Resolve platform once in a typed service and expose signals, rather than calling
getPlatform() ad hoc across the tree.
Capacitor lifecycle
- Plugin lifecycle is asymmetric: register listeners (
App.addListener('appStateChange', ...), 'backButton', 'appUrlOpen', 'resume', 'pause') once at app start, capture the returned handle, and remove it on teardown - a leaked native listener survives the Angular component that created it. Wrap registration in an app-level service whose ngOnDestroy (or DestroyRef) calls removeAllListeners().
- The
App plugin's addListener is async (returns a Promise<PluginListenerHandle>); await the handle before you rely on the listener being live, and store it for removal.
- Own pause/resume, hardware back, and deep links (
appUrlOpen) in that one service, not scattered across pages. On resume, re-read any state that may have gone stale in the background (auth token, geolocation) rather than trusting the pre-pause snapshot.
The Angular zone boundary - wrap every listener callback
Capacitor plugin listener callbacks fire outside Angular's NgZone, so any state they mutate escapes change detection and the UI silently goes stale - the single most common Angular+Capacitor bug. Wrap the body of every listener callback that touches template-bound state - appStateChange, backButton, appUrlOpen, networkStatusChange, the push events - in NgZone.run(); inject NgZone rather than reaching for setTimeout or ApplicationRef.tick(). Under zoneless (Ionic 9 on Angular 21+) the zone is a no-op and the signal write alone repaints - the wrap is harmless there, but the state must be a signal either way. Registration and teardown follow the lifecycle rule above: register in the app-level service, capture the handle, remove on destroy.
Broken - the template never updates:
this.handle = await Network.addListener('networkStatusChange', (status) => {
this.online.set(status.connected); // runs outside the zone
});
Correct - run the mutation inside the zone:
private zone = inject(NgZone);
this.handle = await Network.addListener('networkStatusChange', (status) => {
this.zone.run(() => this.online.set(status.connected));
});
The same wrap is what makes the deep-link Router.navigateByUrl mapping and the push-tap routing actually repaint - both run inside a listener callback.
Android hardware back button
Own the backButton listener in that same app-level service and branch on canGoBack - pop when there is history, exit only when there is none. Never call App.exitApp() unconditionally; it closes the app mid-stack.
App.addListener('backButton', ({ canGoBack }) =>
this.zone.run(() => (canGoBack ? this.location.back() : App.exitApp())));
Native-vs-web fallbacks - degrade, never crash
- Every native call needs a defined web path so the PWA and
ionic serve dev build still run.
- Three fallback shapes, in order of preference: (1) a real web implementation when the plugin ships web support (Capacitor's official plugins mostly do - Camera falls back to file input, Preferences to localStorage); (2) a degraded-but-functional stand-in (share via the Web Share API, or copy-link when even that is absent); (3) an explicit, typed 'unavailable' result the UI can render as a disabled affordance. Prefer the highest one the plugin and target support - a silent no-op is the one outcome to avoid, because it looks like a bug.
- Feature-detect, don't assume: gate on
Capacitor.isPluginAvailable('Camera') and the platform, not on a try/catch that swallows everything.
Permissions - check, explain, request, handle the no
Run the full cycle, in order, for any permission-gated API (camera, geolocation, notifications, contacts):
- Check first with the plugin's
checkPermissions(); only call requestPermissions() when the status is 'prompt' / 'prompt-with-rationale'. Never request blind on app start.
- Request at the point of use, right after a UI affordance that explains why - the OS prompt is one-shot on iOS, so a denial you triggered before the user understood the value is effectively permanent.
- Handle every terminal state explicitly:
'granted', 'denied', and the partial states that matter (iOS 'limited' photo access, coarse-vs-fine location). A denial is a Result the UI renders (a disabled control plus a deep-link to system settings via the App plugin), never an unhandled throw.
- Re-check on resume - the user may have changed the grant in system settings while backgrounded.
Capacitor plugins - sourcing
Preference order when you need a plugin:
- Official
@capacitor/* core plugins first (Camera, Geolocation, Preferences, Filesystem, ...).
- Capawesome
@capawesome/capacitor-* (github.com/capawesome-team/capacitor-plugins) - well-maintained, tracks the current Capacitor major.
- capacitor-community
@capacitor-community/* (the capacitor-community org) for community-maintained needs.
- Vetted community / CapGo only if nothing above fits - never an unmaintained one-off npm package.
Before adopting any third-party plugin: confirm its latest major matches your Capacitor version, check recent releases / commits (maintenance), and verify iOS / Android / web platform support. Per-plugin install and config is fetched live - context7 or the plugin's own README, since it drifts per release; the durable sourcing and typed-wrapping policy is here.
Wrapping - the typed-service contract
- Call a plugin only through a typed Angular service - never the plugin API scattered across components. The service is the single owner of the whole native seam: the permission check, the web-fallback branch, the listener lifecycle, and error mapping (a denied permission or missing capability is a
Result the UI renders, not an unhandled throw).
- The cross-cutting native features nearly every production app hits - push notifications, deep links / universal links, offline-first sync - are each built as one of these services; their house shapes (token lifecycle, URL-to-route mapping, queue-and-drain) live in
references/native-features.md.
Testing the native seams
- Unit-test the wrapping service, not the device: with the plugin mocked (the workspace runner's spy -
vi.fn(), jest.fn(), or jasmine.createSpyObj, per angular-testing), assert the web-fallback branch and the permission-denied path return the typed Result the UI renders. These run in jsdom with no device or emulator.
- Do not try to drive real native plugin behavior in a jsdom unit test - the bridge is not there, so a test that 'exercises' the native path is only exercising your mock. Keep those tests honest about that boundary.
- Reserve the MCP that drives the native mobile shell (an Appium-class server - opt-in and heavy, it needs Xcode / the Android SDK + Java) for true device/E2E smoke of the few native-critical flows (push tap -> route, deep-link cold start, an offline-then-reconnect drain). Smoke the handful that would silently break in production, not the whole surface; with no such server registered, list those flows as UNVERIFIED in the report instead of faking them in jsdom.
1---2name: ionic3description: Ionic / Capacitor mobile + hybrid app conventions - house rules for Ionic Angular UI (standalone + signals, IonRouterOutlet, page-caching view lifecycle, CSS-variable theming), Capacitor lifecycle + platform guards, runtime permissions, and Capacitor plugin sourcing (official -> Capawesome -> capacitor-community) + typed-service wrapping. Targets Ionic 8+ (9 current) / Angular 17+ / Capacitor 6+ (8 current). Load before building or editing an Ionic/Capacitor app - anywhere ionic.config.json or capacitor.config.* lives. Companions: angular-conventions, typescript. Do NOT load for plain web Angular with no native shell.4---56# Ionic / Capacitor Conventions78An Ionic app is an Angular app in a native (Capacitor) shell: the framework rules live in `angular-conventions` and the language baseline in `typescript` - load both. This skill is the Ionic/Capacitor-specific layer of house policy. In-app navigation and the page lifecycle are owned here in `references/navigation-and-lifecycle.md`; broader Ionic UI mechanics (component APIs, theming) are fetched live via context7 or the Ionic docs, not vendored. Per-plugin install/config is fetched live (context7 or the plugin's README); the durable plugin-sourcing and typed-service-wrapping guidance is here in this skill. Cutting a release - the build, signing, store submission, OTA, and release CI - is `capacitor-release`. Security-hardening the native surface - Keychain/Keystore secret storage, permission least-privilege, cleartext and WebView lockdown, deep-link input trust - is `ionic-security`. Version floors, the per-major deltas that bite, and the Ionic + Capacitor upgrade paths live in `references/versions.md`.910## Components and structure11- Standalone components + signals, OnPush, new control flow - same as `angular-conventions`. Ionic components (`IonContent`, `IonList`, ...) are standalone imports, not a shared module.12- Theme through Ionic CSS variables and `color` / `mode`, not hardcoded colors; keep design tokens in one place. Respect the system light/dark setting.13- Handle safe-area / edge-to-edge insets with the CSS `env(safe-area-inset-*)` variables (Ionic's `--ion-safe-area-*`), never fixed padding - Capacitor 8 draws content under the status and navigation bars by default (it dropped `adjustMarginsForEdgeToEdge` for a System Bars core plugin plus these CSS variables), so pad it back with the insets; the System Bars plugin API is fetched live.14- Dark mode is a palette you opt into, not per-component overrides: import Ionic's dark palette and choose the strategy - follow the OS (system) or an app toggle (the ion-palette-dark class on the root). Theme off the palette's CSS variables; never hand-roll dark colours per component. The v8 import files and the step-token split are in `references/versions.md`.15- Respect the OS accessibility settings: Ionic scales type to the device Dynamic Type / font-size setting by default (the `--ion-dynamic-font` token) - size with relative units and check large-text layouts, never fixed `px` that clips. Keep touch targets >= 44px and give every interactive control an accessible name.16- Keep page components thin: data + state in services/stores, presentation in the page.17- Import Ionic UI components and `provideIonicAngular()` from the standalone entry point the installed Ionic major documents - `@ionic/angular/standalone` on Ionic 8, where the bare `@ionic/angular` barrel pulls in lazy-loaded code that defeats tree-shaking; on Ionic 9 standalone IS the default `@ionic/angular` entry and the lazy barrel moved to `@ionic/angular/lazy`. Resolve the path via context7 against the workspace's installed `@ionic/angular` major before the first import, never from recall.1819## Form controls - the modern syntax20- Label and validation live on the control, not slotted into `IonItem`: `IonInput` / `IonTextarea` / `IonSelect` carry `label`, `labelPlacement`, `fill` (`outline` / `solid`), `helperText`, `errorText`, and `counter` directly. Ionic 8 removed the legacy `IonItem`-wrapped form pattern and the `legacy` property - never author it or paste it from an old sample.21- The control's `label` (or an `[aria-label]` when it is visually labelled elsewhere) IS its accessible name - a field with neither fails the a11y gate. Build forms with typed reactive `FormGroup`s and surface validation through one shared `errorText` path, not a per-field `@if` error wall. That holds even where `angular-conventions` prefers Signal Forms (v21+): Ionic's controls are documented and tested against the reactive-forms path, so Signal Forms waits on Ionic surfaces until Ionic documents support for the installed major - check the Ionic docs via context7 before assuming, never recall.2223## Overlays - modal, popover, toast, alert, action-sheet, loading24- Prefer the inline component with `[isOpen]` bound to a signal and `(didDismiss)` handled over the imperative `*Controller` - overlay state stays in the component and tears down cleanly. Reach for the controller only for a genuinely fire-and-forget prompt.25- Always read the dismissal: handle the backdrop tap, the hardware back, and the returned `role` on `didDismiss` - an overlay whose result you never read is a dropped user decision. Per-overlay component options are fetched live.2627## Change detection and zoneless28- OnPush everywhere except the shell: never put OnPush on a component that hosts `IonRouterOutlet` or `IonNav`. It stops lifecycle hooks such as `ngOnInit` from firing and breaks async rendering (Ionic's own docs). Keep those shell components eagerly checked - `ChangeDetectionStrategy.Default`, renamed `Eager` in Angular 22, where OnPush became the framework default so the shell now opts out explicitly; apply OnPush only to leaf pages and presentational components.29- Zoneless is gated by the Ionic major, not the Angular one. Ionic 8 keeps Zone.js as a peer dependency and is not zoneless-compatible - treat zoneless as unsupported there whatever Angular runs underneath, and keep `zone.js` in the polyfills; signals are still fine in your layer, they just don't make Ionic's components zoneless. Ionic 9 ships official zoneless support (Angular 21+ defaults to it): a plain field set from an async callback no longer repaints on its own, so state flows through signals (or `markForCheck()`) - which the signals-first rules above already satisfy. Check the installed major before deciding; the 8 -> 9 delta is in `references/versions.md`.3031## Navigation32- Route with the Angular router inside an `IonRouterOutlet`; lazy-load every feature route via `loadComponent` / `loadChildren`. Tabs use `IonTabs` with their own outlet.33- Don't mix Ionic's imperative nav controllers with the Angular router in one app - pick the router and stay with it.34- Don't add `withViewTransitions()` to the router: `IonRouterOutlet` owns the page-stack transitions, and the two animation systems fight - double or broken transitions.3536## Ionic page lifecycle37- Ionic caches pages in the DOM, so `ngOnInit` / `ngOnDestroy` fire only on create/pop, not on every revisit - route refresh-on-entry work onto `ionViewWillEnter`, deferred heavy work onto `ionViewDidEnter`. The full hook schedule and which hook owns which work are in `references/navigation-and-lifecycle.md`.38- Control navigation with Angular route guards (`CanActivate` / `CanDeactivate`) - they replaced the old `ionViewCanEnter` / `ionViewCanLeave`. Guards yes, route resolvers no for refresh-on-entry data: a cached page's revisit re-activates nothing, so a resolver never re-runs - that data belongs on `ionViewWillEnter`.3940## Large lists41- Ionic's own virtual-scroll component was removed in v7 - for long lists use Angular CDK virtual scroll (`CdkVirtualScrollViewport` with `*cdkVirtualFor`) inside `IonContent`: set `[scrollY]="false"` on the `IonContent` and add the ion-content-scroll-host class to the viewport so Ionic's pull-to-refresh and infinite scroll keep working. CDK handles fixed-height rows well; variable-height rows can jank.4243## Platform detection - pick the right check for the question44Three different questions, three different calls - don't conflate them:45- 'Is there a native bridge at all?' -> `Capacitor.isNativePlatform()` (true on iOS and Android, false in a browser / PWA). This is the gate for any code that calls a native plugin path.46- 'Which OS?' -> `Capacitor.getPlatform()` returns `'ios' | 'android' | 'web'`. Branch on it only for genuinely platform-specific behavior (a status-bar inset, an iOS-only API), never as a substitute for the native check above.47- 'What can the app do right now?' -> Ionic's `Platform` service: `platform.is('ios' | 'mobile' | 'pwa' | 'desktop' | 'capacitor')` plus `platform.ready()`. Prefer `Platform` inside Angular components because it injects cleanly and is mockable in tests; reserve the static `Capacitor.*` calls for plain functions and services with no injection context.48- Resolve platform once in a typed service and expose signals, rather than calling `getPlatform()` ad hoc across the tree.4950## Capacitor lifecycle51- Plugin lifecycle is asymmetric: register listeners (`App.addListener('appStateChange', ...)`, `'backButton'`, `'appUrlOpen'`, `'resume'`, `'pause'`) once at app start, capture the returned handle, and remove it on teardown - a leaked native listener survives the Angular component that created it. Wrap registration in an app-level service whose `ngOnDestroy` (or `DestroyRef`) calls `removeAllListeners()`.52- The `App` plugin's `addListener` is async (returns a `Promise<PluginListenerHandle>`); await the handle before you rely on the listener being live, and store it for removal.53- Own pause/resume, hardware back, and deep links (`appUrlOpen`) in that one service, not scattered across pages. On resume, re-read any state that may have gone stale in the background (auth token, geolocation) rather than trusting the pre-pause snapshot.5455## The Angular zone boundary - wrap every listener callback56Capacitor plugin listener callbacks fire outside Angular's `NgZone`, so any state they mutate escapes change detection and the UI silently goes stale - the single most common Angular+Capacitor bug. Wrap the body of every listener callback that touches template-bound state - `appStateChange`, `backButton`, `appUrlOpen`, `networkStatusChange`, the push events - in `NgZone.run()`; inject `NgZone` rather than reaching for `setTimeout` or `ApplicationRef.tick()`. Under zoneless (Ionic 9 on Angular 21+) the zone is a no-op and the signal write alone repaints - the wrap is harmless there, but the state must be a signal either way. Registration and teardown follow the lifecycle rule above: register in the app-level service, capture the handle, remove on destroy.5758Broken - the template never updates:59```typescript60this.handle = await Network.addListener('networkStatusChange', (status) => {61 this.online.set(status.connected); // runs outside the zone62});63```6465Correct - run the mutation inside the zone:66```typescript67private zone = inject(NgZone);68this.handle = await Network.addListener('networkStatusChange', (status) => {69 this.zone.run(() => this.online.set(status.connected));70});71```7273The same wrap is what makes the deep-link `Router.navigateByUrl` mapping and the push-tap routing actually repaint - both run inside a listener callback.7475### Android hardware back button76Own the `backButton` listener in that same app-level service and branch on `canGoBack` - pop when there is history, exit only when there is none. Never call `App.exitApp()` unconditionally; it closes the app mid-stack.77```typescript78App.addListener('backButton', ({ canGoBack }) =>79 this.zone.run(() => (canGoBack ? this.location.back() : App.exitApp())));80```8182## Native-vs-web fallbacks - degrade, never crash83- Every native call needs a defined web path so the PWA and `ionic serve` dev build still run.84- Three fallback shapes, in order of preference: (1) a real web implementation when the plugin ships web support (Capacitor's official plugins mostly do - Camera falls back to file input, Preferences to localStorage); (2) a degraded-but-functional stand-in (share via the Web Share API, or copy-link when even that is absent); (3) an explicit, typed 'unavailable' result the UI can render as a disabled affordance. Prefer the highest one the plugin and target support - a silent no-op is the one outcome to avoid, because it looks like a bug.85- Feature-detect, don't assume: gate on `Capacitor.isPluginAvailable('Camera')` and the platform, not on a try/catch that swallows everything.8687## Permissions - check, explain, request, handle the no88Run the full cycle, in order, for any permission-gated API (camera, geolocation, notifications, contacts):89- Check first with the plugin's `checkPermissions()`; only call `requestPermissions()` when the status is `'prompt'` / `'prompt-with-rationale'`. Never request blind on app start.90- Request at the point of use, right after a UI affordance that explains why - the OS prompt is one-shot on iOS, so a denial you triggered before the user understood the value is effectively permanent.91- Handle every terminal state explicitly: `'granted'`, `'denied'`, and the partial states that matter (iOS `'limited'` photo access, coarse-vs-fine location). A denial is a `Result` the UI renders (a disabled control plus a deep-link to system settings via the App plugin), never an unhandled throw.92- Re-check on resume - the user may have changed the grant in system settings while backgrounded.9394## Capacitor plugins - sourcing95Preference order when you need a plugin:961. **Official** `@capacitor/*` core plugins first (Camera, Geolocation, Preferences, Filesystem, ...).972. **Capawesome** `@capawesome/capacitor-*` (github.com/capawesome-team/capacitor-plugins) - well-maintained, tracks the current Capacitor major.983. **capacitor-community** `@capacitor-community/*` (the capacitor-community org) for community-maintained needs.994. Vetted community / CapGo only if nothing above fits - never an unmaintained one-off npm package.100101Before adopting any third-party plugin: confirm its latest major matches your Capacitor version, check recent releases / commits (maintenance), and verify iOS / Android / web platform support. Per-plugin install and config is fetched live - context7 or the plugin's own README, since it drifts per release; the durable sourcing and typed-wrapping policy is here.102103## Wrapping - the typed-service contract104- Call a plugin only through a typed Angular service - never the plugin API scattered across components. The service is the single owner of the whole native seam: the permission check, the web-fallback branch, the listener lifecycle, and error mapping (a denied permission or missing capability is a `Result` the UI renders, not an unhandled throw).105- The cross-cutting native features nearly every production app hits - push notifications, deep links / universal links, offline-first sync - are each built as one of these services; their house shapes (token lifecycle, URL-to-route mapping, queue-and-drain) live in `references/native-features.md`.106107## Testing the native seams108- Unit-test the wrapping service, not the device: with the plugin mocked (the workspace runner's spy - `vi.fn()`, `jest.fn()`, or `jasmine.createSpyObj`, per `angular-testing`), assert the web-fallback branch and the permission-denied path return the typed `Result` the UI renders. These run in jsdom with no device or emulator.109- Do not try to drive real native plugin behavior in a jsdom unit test - the bridge is not there, so a test that 'exercises' the native path is only exercising your mock. Keep those tests honest about that boundary.110- Reserve the MCP that drives the native mobile shell (an Appium-class server - opt-in and heavy, it needs Xcode / the Android SDK + Java) for true device/E2E smoke of the few native-critical flows (push tap -> route, deep-link cold start, an offline-then-reconnect drain). Smoke the handful that would silently break in production, not the whole surface; with no such server registered, list those flows as UNVERIFIED in the report instead of faking them in jsdom.111112<!-- House Ionic/Capacitor conventions; in-app navigation + page lifecycle owned in references/navigation-and-lifecycle.md, the push/deep-link/offline shapes in references/native-features.md; component APIs / theming fetched live via context7 / the Ionic docs. -->