@erkamyaman/capacitor-foldable 8.3.0 → 8.3.2

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/README.md CHANGED
@@ -37,7 +37,7 @@ const { state, hingeOrientation } = await Foldable.getFoldState();
37
37
 
38
38
  | Platform | Status | Minimum |
39
39
  | -------- | ------ | ------- |
40
- | Android | Supported | API 24, `compileSdk` 34 |
40
+ | Android | Supported | API 24, `compileSdk` 36 |
41
41
  | iOS | Size classes everywhere; fold, hinge and bar placement on iPhone Duo with Capacitor 8.5+, Xcode 27.1+ and iOS 27.1+ | iOS 15 |
42
42
  | Web | Stub, returns `flat` | |
43
43
 
@@ -53,7 +53,7 @@ Web views already resize with the window, so a responsive layout that pads with
53
53
  - **Where iPhone Duo puts native bars**, so your HTML tab bar can follow. For Ionic's `ion-tabs` there's a ready-made stylesheet, plus a fix for the tab bar disappearing when you fold.
54
54
  - **The outer display on Android foldables**: rear display and dual screen.
55
55
 
56
- Google's [adaptive app quality guidelines](https://developer.android.com/develop/adaptive-apps/quality-guidelines/adaptive-app-quality) put window size classes and posture support in Tier 1, and Apple's [iPhone Duo guidance](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo) asks for layouts that adapt to the fold rather than to a device. This plugin reports what both checklists are about.
56
+ Google's [adaptive app quality guidelines](https://developer.android.com/develop/adaptive-apps/quality-guidelines/adaptive-app-quality) make posture support a Tier 1 check and size-class-driven layouts a Tier 2 one, and Apple's [iPhone Duo guidance](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo) asks for layouts that adapt to the fold rather than to a device. This plugin reports what both are about.
57
57
 
58
58
  ## Installation
59
59
 
@@ -80,7 +80,7 @@ More in [Examples](docs/examples.md).
80
80
 
81
81
  ## Web standard APIs
82
82
 
83
- Chrome ships the [Device Posture API](https://developer.mozilla.org/docs/Web/API/Device_Posture_API) and the [Viewport Segments API](https://developer.mozilla.org/docs/Web/API/Viewport_Segments_API), but Android's WebView turns both off and Safari doesn't support them, so neither works inside a Capacitor app. `installFoldablePolyfill()` fills them in from the native fold state, and mirrors the CSS features as classes and variables on `<html>`:
83
+ Chrome ships the [Device Posture API](https://developer.mozilla.org/docs/Web/API/Device_Posture_API) and the [Viewport Segments API](https://developer.mozilla.org/docs/Web/API/Viewport_Segments_API), but Android's WebView turns both off. Safari 27.1 has them behind a feature flag that is off by default and belongs to Safari, so a `WKWebView` inside your app cannot switch it on. Neither works in a Capacitor app today. `installFoldablePolyfill()` fills them in from the native fold state, and mirrors the CSS features as classes and variables on `<html>`:
84
84
 
85
85
  ```typescript
86
86
  import { installFoldablePolyfill } from '@erkamyaman/capacitor-foldable';
@@ -127,11 +127,11 @@ Using Ionic's `ion-tabs`? `import '@erkamyaman/capacitor-foldable/ionic-tabs.css
127
127
 
128
128
  ## Store review
129
129
 
130
- The plugin is built so it cannot be the reason an app is rejected.
130
+ The plugin is built to give store review nothing to object to.
131
131
 
132
- **iOS.** Public APIs only, UIKit and Capacitor, with no dynamic lookup or private symbols. It touches none of Apple's [required reason APIs](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files), collects nothing and tracks nobody, and it ships a `PrivacyInfo.xcprivacy` saying exactly that, which Xcode folds into your app's privacy report. Nothing it uses needs a purpose string in `Info.plist`. The iOS 27.1 code is behind a compile-time SDK check, so older Xcode versions build fine.
132
+ **iOS.** Public APIs only, UIKit and Capacitor, with no dynamic lookup or private symbols. It touches none of Apple's [required reason APIs](https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api), collects nothing and tracks nobody, and it ships a `PrivacyInfo.xcprivacy` saying exactly that, which Xcode folds into your app's privacy report. Nothing it uses needs a purpose string in `Info.plist`. The iOS 27.1 code is behind a compile-time SDK check, so any Xcode that Capacitor 8 supports builds fine.
133
133
 
134
- **Android.** The plugin's manifest is empty: it adds no permissions to your app. The hinge angle comes from `TYPE_HINGE_ANGLE`, which needs no permission, and it is registered at `SENSOR_DELAY_NORMAL`, well under the rate that would require `HIGH_SAMPLING_RATE_SENSORS`. Rear display and dual screen go through Jetpack WindowManager's `WindowAreaController`, also permission free. `minSdk` is 24 and `targetSdk` follows your project, so it never holds you back from Play's target API rules.
134
+ **Android.** The plugin adds no permissions and no `uses-feature` to your app, so it changes nothing about which devices can install it. Its own manifest is empty; Jetpack WindowManager merges two optional `<uses-library>` entries, which are how it binds to the OEM window extensions and filter nothing. The hinge angle comes from `TYPE_HINGE_ANGLE`, which needs no permission, and rear display and dual screen go through `WindowAreaController`, also permission free. `minSdk` is 24, the same as Capacitor's own default, and `targetSdk` follows your project.
135
135
 
136
136
  **Data safety and privacy labels.** Everything stays on the device: the plugin has no network code and stores nothing. There is no entry for it to add to Play's Data safety form or to your App Store privacy label.
137
137
 
@@ -572,9 +572,9 @@ Only fires on iOS 27.1 or later.
572
572
 
573
573
  #### BarPlacement
574
574
 
575
- | Prop | Type | Description | Since |
576
- | --------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
577
- | **`verticalBarEdge`** | <code>'leading' \| 'trailing' \| null</code> | The edge iPhone Duo moves tab bars and toolbars to when it lays them out vertically: `'leading'` or `'trailing'` in the reading direction, so `'leading'` is the left edge in left-to-right languages. `null` when bars stay horizontal, and always on Android, web and iOS before 27.1. | 7.0.0 |
575
+ | Prop | Type | Description | Since |
576
+ | --------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
577
+ | **`verticalBarEdge`** | <code>'leading' \| 'trailing' \| null</code> | The edge iPhone Duo moves tab bars and toolbars to when it lays them out vertically: `'leading'` or `'trailing'` in the reading direction, so `'leading'` is the left edge in left-to-right languages. `null` when bars stay horizontal, and always on Android, web and iOS before 27.1. An app built with an older Xcode still gets the edge: it is inferred from the safe-area inset the bar leaves, since the trait that names it is missing. | 7.0.0 |
578
578
 
579
579
 
580
580
  #### PluginListenerHandle
@@ -600,3 +600,9 @@ angle: iOS keeps reporting `'closed'` for a moment after the phone opens.
600
600
  <code>'unsupported' | 'unavailable' | 'available' | 'active'</code>
601
601
 
602
602
  </docgen-api>
603
+
604
+ ## Trademarks and licence
605
+
606
+ This plugin is MIT licensed, and that covers the work in this repository. Quoted passages from Apple's and Google's documentation belong to them and are used here to explain their guidance.
607
+
608
+ iPhone and iPhone Duo are trademarks of Apple Inc., registered in the U.S. and other countries. Android and Pixel are trademarks of Google LLC. Galaxy Z Fold and Galaxy Z Flip are trademarks of Samsung Electronics Co., Ltd. This project is not affiliated with, endorsed by, or sponsored by any of them; the names appear only to say which devices the plugin works with.
@@ -3,6 +3,7 @@ package io.github.erkamyaman.foldable
3
3
  import android.app.Activity
4
4
  import android.content.Context
5
5
  import android.view.View
6
+ import android.webkit.WebView
6
7
  import androidx.core.content.ContextCompat
7
8
  import androidx.window.area.WindowAreaCapability
8
9
  import androidx.window.area.WindowAreaController
@@ -31,6 +32,7 @@ class DisplayModes(private val activity: Activity) {
31
32
  private var rearDisplayArea: WindowAreaInfo? = null
32
33
  private var rearDisplaySession: WindowAreaSession? = null
33
34
  private var dualScreenSession: WindowAreaSessionPresenter? = null
35
+ private var dualScreenContent: WebView? = null
34
36
 
35
37
  fun statuses(): Flow<DisplayModeStatuses> =
36
38
  controller.windowAreaInfos
@@ -72,7 +74,7 @@ class DisplayModes(private val activity: Activity) {
72
74
 
73
75
  fun startDualScreen(createContent: (Context) -> View, onStarted: () -> Unit, onEnded: (Throwable?) -> Unit) {
74
76
  dualScreenSession?.let { session ->
75
- session.setContentView(createContent(session.context))
77
+ replaceContent(session, createContent)
76
78
  onStarted()
77
79
  return
78
80
  }
@@ -86,11 +88,12 @@ class DisplayModes(private val activity: Activity) {
86
88
  object : WindowAreaPresentationSessionCallback {
87
89
  override fun onSessionStarted(session: WindowAreaSessionPresenter) {
88
90
  dualScreenSession = session
89
- session.setContentView(createContent(session.context))
91
+ replaceContent(session, createContent)
90
92
  onStarted()
91
93
  }
92
94
 
93
95
  override fun onSessionEnded(t: Throwable?) {
96
+ destroyContent()
94
97
  dualScreenSession = null
95
98
  onEnded(t)
96
99
  }
@@ -101,10 +104,27 @@ class DisplayModes(private val activity: Activity) {
101
104
  }
102
105
 
103
106
  fun stopDualScreen() {
107
+ destroyContent()
104
108
  dualScreenSession?.close()
105
109
  dualScreenSession = null
106
110
  }
107
111
 
112
+ /** A web view left alive keeps its timers and connections running. */
113
+ private fun replaceContent(session: WindowAreaSessionPresenter, createContent: (Context) -> View) {
114
+ destroyContent()
115
+ val content = createContent(session.context)
116
+ dualScreenContent = content as? WebView
117
+ session.setContentView(content)
118
+ }
119
+
120
+ private fun destroyContent() {
121
+ dualScreenContent?.apply {
122
+ loadUrl("about:blank")
123
+ destroy()
124
+ }
125
+ dualScreenContent = null
126
+ }
127
+
108
128
  private fun statusOf(area: WindowAreaInfo?, operation: WindowAreaCapability.Operation): String =
109
129
  when (area?.getCapability(operation)?.status) {
110
130
  WindowAreaCapability.Status.WINDOW_AREA_STATUS_ACTIVE -> "active"
@@ -216,13 +216,15 @@ class FoldablePlugin : Plugin() {
216
216
  @PluginMethod
217
217
  fun startDualScreen(call: PluginCall) {
218
218
  val url = call.getString("url") ?: return call.reject("url is required.")
219
+ val target = resolveUrl(url)
220
+ ?: return call.reject("url must be a page in this app, or a data: URL you built yourself.")
219
221
  val displayModes = this.displayModes ?: return call.unavailable("Dual-screen mode needs an activity.")
220
222
 
221
223
  scope.launch {
222
224
  when (lastDisplayModes?.dualScreen) {
223
225
  "available", "active" -> {
224
226
  val settle = Settle(call, "Dual-screen mode")
225
- displayModes.startDualScreen({ context -> dualScreenWebView(context, url) }, settle::started, settle::ended)
227
+ displayModes.startDualScreen({ context -> dualScreenWebView(context, target) }, settle::started, settle::ended)
226
228
  }
227
229
  else -> call.unavailable("Dual-screen mode is not available on this device right now.")
228
230
  }
@@ -294,12 +296,32 @@ class FoldablePlugin : Plugin() {
294
296
  webViewClient = object : WebViewClient() {
295
297
  override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? =
296
298
  bridge.localServer.shouldInterceptRequest(request)
299
+
300
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
301
+ !isAppUrl(request.url.toString())
297
302
  }
298
- loadUrl(resolveUrl(url))
303
+ loadUrl(url)
304
+ }
305
+
306
+ /**
307
+ * The page on the other display shares the app's web view profile, so it is
308
+ * kept to the app's own origin: anything else is refused rather than loaded.
309
+ */
310
+ private fun resolveUrl(url: String): String? {
311
+ if (url.startsWith("//")) return null
312
+
313
+ val scheme = Uri.parse(url).scheme
314
+ if (scheme == null) return URL(URL(bridge.appUrl), url).toString().takeIf(::isAppUrl)
315
+ if (!scheme.equals("http", true) && !scheme.equals("https", true) && scheme != "data") return null
316
+
317
+ return url.takeIf { scheme == "data" || isAppUrl(it) }
299
318
  }
300
319
 
301
- private fun resolveUrl(url: String): String =
302
- if (Uri.parse(url).scheme != null) url else URL(URL(bridge.appUrl), url).toString()
320
+ private fun isAppUrl(url: String): Boolean {
321
+ val app = Uri.parse(bridge.appUrl)
322
+ val target = Uri.parse(url)
323
+ return target.scheme == app.scheme && target.host == app.host && target.port == app.port
324
+ }
303
325
 
304
326
  private inner class Settle(private val call: PluginCall, private val mode: String) {
305
327
  private var settled = false
package/dist/docs.json CHANGED
@@ -684,7 +684,7 @@
684
684
  "name": "since"
685
685
  }
686
686
  ],
687
- "docs": "The edge iPhone Duo moves tab bars and toolbars to when it lays them out\nvertically: `'leading'` or `'trailing'` in the reading direction, so\n`'leading'` is the left edge in left-to-right languages. `null` when bars\nstay horizontal, and always on Android, web and iOS before 27.1.",
687
+ "docs": "The edge iPhone Duo moves tab bars and toolbars to when it lays them out\nvertically: `'leading'` or `'trailing'` in the reading direction, so\n`'leading'` is the left edge in left-to-right languages. `null` when bars\nstay horizontal, and always on Android, web and iOS before 27.1. An app\nbuilt with an older Xcode still gets the edge: it is inferred from the\nsafe-area inset the bar leaves, since the trait that names it is missing.",
688
688
  "complexTypes": [],
689
689
  "type": "'leading' | 'trailing' | null"
690
690
  }
@@ -183,7 +183,9 @@ export interface BarPlacement {
183
183
  * The edge iPhone Duo moves tab bars and toolbars to when it lays them out
184
184
  * vertically: `'leading'` or `'trailing'` in the reading direction, so
185
185
  * `'leading'` is the left edge in left-to-right languages. `null` when bars
186
- * stay horizontal, and always on Android, web and iOS before 27.1.
186
+ * stay horizontal, and always on Android, web and iOS before 27.1. An app
187
+ * built with an older Xcode still gets the edge: it is inferred from the
188
+ * safe-area inset the bar leaves, since the trait that names it is missing.
187
189
  *
188
190
  * @since 7.0.0
189
191
  */
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\nexport interface FoldState {\n /**\n * Posture of the fold. `'closed'` is never reported today: a device shut on\n * its cover display reports `'flat'`.\n *\n * @since 7.0.0\n */\n state: 'flat' | 'half-opened' | 'closed';\n\n /**\n * Whether the fold splits the web view into two areas: `true` when\n * half-opened, or when the hinge has a physical gap.\n *\n * @since 7.0.0\n */\n isSeparating: boolean;\n\n /**\n * How the device is held: `'tabletop'` when half-opened with a horizontal\n * hinge, like a laptop, `'book'` when half-opened with a vertical hinge, and\n * `'flat'` otherwise.\n *\n * @since 7.0.0\n */\n posture: 'flat' | 'tabletop' | 'book';\n\n /**\n * Direction of the hinge relative to the window, so it flips when the device\n * rotates. Omitted when there is no fold.\n *\n * @since 7.0.0\n */\n hingeOrientation?: 'horizontal' | 'vertical';\n\n /**\n * Position of the fold in CSS pixels, relative to the web view. Zero wide (or\n * zero tall) on a seamless fold. Omitted when there is no fold.\n *\n * @since 7.0.0\n */\n hingeBounds?: { x: number; y: number; width: number; height: number };\n\n /**\n * Area of the web view the hinge covers, in CSS pixels. Only present on\n * devices with a physical gap.\n *\n * @since 7.0.0\n */\n occludedBounds?: { x: number; y: number; width: number; height: number };\n\n /**\n * Which display is showing the app, on a device with an inner and an outer\n * display such as iPhone Duo. Reported on iPhone Duo (iOS 27.1 or later) and\n * omitted elsewhere.\n *\n * @since 7.0.0\n */\n activeDisplay?: 'inner' | 'outer';\n\n /**\n * Areas of the web view something covers, in CSS pixels. On Android these are\n * the display cutouts. On iPhone Duo these are the active occlusion regions:\n * the camera in use, and the strip the system keeps for the vertical status\n * bar. Use `getReservedRegions()` when you need to tell them apart. Omitted\n * when there are none.\n *\n * @since 7.0.0\n */\n cameraBounds?: { x: number; y: number; width: number; height: number }[];\n\n /**\n * Space the system asks you to keep clear around the fold, in CSS pixels.\n * `hingeBounds` covers the crease plus these margins, so the crease itself is\n * `hingeBounds` shrunk by them. iPhone Duo reports 20 on each side of a\n * vertical fold. Omitted on Android and where there is no fold.\n *\n * @since 8.2.0\n */\n hingeMargins?: { top: number; right: number; bottom: number; left: number };\n}\n\nexport interface ReservedRegion {\n /**\n * `'division'` is the fold itself. `'occlusion'` is something covering the\n * display, such as a camera or the vertical status bar area.\n *\n * @since 8.2.0\n */\n kind: 'division' | 'occlusion';\n\n /**\n * Whether the region applies right now. An inactive division is a flat fold,\n * and an inactive occlusion is a camera that is not in use.\n *\n * @since 8.2.0\n */\n isActive: boolean;\n\n x: number;\n y: number;\n width: number;\n height: number;\n\n /**\n * Space to keep clear around the region, in CSS pixels.\n *\n * @since 8.2.0\n */\n margins: { top: number; right: number; bottom: number; left: number };\n}\n\n/**\n * How far open the hinge is, as the system sees it. Note that it can lag the\n * angle: iOS keeps reporting `'closed'` for a moment after the phone opens.\n *\n * @since 8.2.0\n */\nexport type HingeStatus = 'closed' | 'partiallyOpen' | 'fullyOpen';\n\nexport interface SizeClass {\n /**\n * Width size class of the window: `'compact'` on a phone and on the outer\n * display of a foldable, `'regular'` on the inner display, tablets and wide\n * windows. On Android and web `'regular'` starts at 600 CSS pixels.\n *\n * @since 7.0.0\n */\n horizontal: 'compact' | 'regular';\n\n /**\n * Height size class of the window: `'compact'` on a phone in landscape. On\n * Android and web `'regular'` starts at 480 CSS pixels.\n *\n * @since 7.0.0\n */\n vertical: 'compact' | 'regular';\n\n /**\n * Material window width class, from the window width in CSS pixels:\n * `'compact'` below 600, `'medium'` below 840, `'expanded'` below 1200,\n * `'large'` below 1600 and `'extraLarge'` from 1600.\n *\n * @since 7.0.0\n */\n widthClass: 'compact' | 'medium' | 'expanded' | 'large' | 'extraLarge';\n\n /**\n * Material window height class, from the window height in CSS pixels:\n * `'compact'` below 480, `'medium'` below 900 and `'expanded'` from 900.\n *\n * @since 7.0.0\n */\n heightClass: 'compact' | 'medium' | 'expanded';\n}\n\nexport type DisplayModeStatus = 'unsupported' | 'unavailable' | 'available' | 'active';\n\nexport interface DisplayModes {\n /**\n * Rear display mode moves the app to the outer display, so people can frame a\n * photo with the rear cameras. Only on Android foldables that offer it.\n *\n * @since 7.0.0\n */\n rearDisplay: DisplayModeStatus;\n\n /**\n * Dual-screen mode shows a second page on the outer display while the app\n * stays on the inner one. Only on Android foldables that offer it.\n *\n * @since 7.0.0\n */\n dualScreen: DisplayModeStatus;\n}\n\nexport interface BarPlacement {\n /**\n * The edge iPhone Duo moves tab bars and toolbars to when it lays them out\n * vertically: `'leading'` or `'trailing'` in the reading direction, so\n * `'leading'` is the left edge in left-to-right languages. `null` when bars\n * stay horizontal, and always on Android, web and iOS before 27.1.\n *\n * @since 7.0.0\n */\n verticalBarEdge: 'leading' | 'trailing' | null;\n}\n\nexport interface FoldablePolyfillOptions {\n /**\n * Take over hiding Ionic's tab bar while the keyboard is open. iPhone Duo\n * reports a keyboard on every fold, and Ionic waits for the window to return\n * to the height it had when the keyboard first opened, so its tab bar can\n * disappear until the device is folded back. With this on, the keyboard events\n * never reach Ionic, and a `foldable-keyboard-open` class on `<html>` marks\n * when a text field really has the keyboard. `ionic-tabs.css` hides the tab\n * bar with it.\n *\n * @default false\n * @since 8.0.2\n */\n ionicKeyboard?: boolean;\n}\n\nexport interface FoldablePlugin {\n /**\n * Whether the device has a fold at all, and whether it can be propped half\n * open like a laptop. Both `false` on web, and on iOS except on iPhone Duo\n * (iOS 27.1 or later).\n *\n * @since 7.0.0\n */\n isDeviceFoldable(): Promise<{ foldable: boolean; supportsTabletop: boolean }>;\n\n /**\n * Read the current fold state. Resolves to\n * `{ state: 'flat', isSeparating: false, posture: 'flat' }` when there is no\n * fold information.\n *\n * @since 7.0.0\n */\n getFoldState(): Promise<FoldState>;\n\n /**\n * Read the angle between the two halves of the device, in degrees: `0` when\n * closed, `180` when flat. Resolves to `{ angle: null }` on devices without a\n * hinge angle sensor, on web, and on iOS before 27.1.\n *\n * @since 7.0.0\n */\n getHingeAngle(): Promise<{ angle: number | null; status: HingeStatus | null }>;\n\n /**\n * Read every region the system reserves on this display: the fold and\n * anything covering the screen, active or not. iPhone Duo reports the fold,\n * the vertical status bar area and the under-display camera. Resolves to an\n * empty list on Android, on web and on iOS before 27.1.\n *\n * @since 8.2.0\n */\n getReservedRegions(): Promise<{ regions: ReservedRegion[] }>;\n\n /**\n * Choose whether iPhone Duo may move this app's bars to the side of the\n * display. `'disabled'` keeps everything horizontal, including the status\n * bar. Resolves to `{ applied: false }` when the app does not use\n * `FoldableBridgeViewController`, on Android, on web and on iOS before 27.1.\n * See the iPhone Duo guide for the one-line storyboard change.\n *\n * @since 8.2.0\n */\n setVerticalBarBehavior(options: { behavior: 'automatic' | 'disabled' }): Promise<{ applied: boolean }>;\n\n /**\n * Read the window's size classes: Apple's compact and regular, the signal its\n * iPhone Duo guidelines recommend for telling the outer display from the inner\n * one, and Material's width and height classes. On iOS `horizontal` and\n * `vertical` are UIKit's size classes; everything else comes from the window\n * size.\n *\n * @since 7.0.0\n */\n getSizeClass(): Promise<SizeClass>;\n\n /**\n * Read which of the foldable display modes the device offers right now. Both\n * are `'unsupported'` on iOS and web.\n *\n * @since 7.0.0\n */\n getDisplayModes(): Promise<DisplayModes>;\n\n /**\n * Read where native bars go. Native tab bars and toolbars move to the side of\n * the display on iPhone Duo, but HTML ones stay put, so use this to move your\n * own tab bar too.\n *\n * @since 7.0.0\n */\n getBarPlacement(): Promise<BarPlacement>;\n\n /**\n * Move the app to the outer display. Android asks the user to confirm first,\n * and the promise resolves once the app has moved. Rejects when rear display\n * mode is not `'available'`. Only on Android.\n *\n * @since 7.0.0\n */\n startRearDisplay(): Promise<void>;\n\n /**\n * Move the app back to the inner display.\n *\n * @since 7.0.0\n */\n stopRearDisplay(): Promise<void>;\n\n /**\n * Show a page on the outer display while the app stays on the inner one. A\n * relative `url` resolves against the app's own URL, so `'cover.html'` loads a\n * page bundled with the app. The page runs in its own web view without access\n * to Capacitor plugins. Calling this again while dual-screen mode is active\n * replaces the page. Only on Android.\n *\n * @since 7.0.0\n */\n startDualScreen(options: { url: string }): Promise<void>;\n\n /**\n * Close the page on the outer display.\n *\n * @since 7.0.0\n */\n stopDualScreen(): Promise<void>;\n\n /**\n * Listen for fold state changes. On a foldable this also fires when the\n * device rotates, because `hingeOrientation` and `hingeBounds` rotate with\n * the window.\n *\n * @since 7.0.0\n */\n addListener(eventName: 'foldStateChange', listenerFunc: (state: FoldState) => void): Promise<PluginListenerHandle>;\n\n /**\n * Listen for hinge angle changes. On Android the hinge sensor only runs while\n * at least one of these listeners is registered. On iOS it fires on iPhone\n * Duo (iOS 27.1 or later). Never fires on web.\n *\n * @since 7.0.0\n */\n addListener(\n eventName: 'hingeAngleChange',\n listenerFunc: (event: { angle: number }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for size class changes, such as unfolding the device, rotating it or\n * resizing the window. On iOS this fires when UIKit's size classes change\n * (iOS 17 or later) or the device rotates, so a resize that keeps the same\n * size classes may not update `widthClass` and `heightClass` until then.\n *\n * @since 7.0.0\n */\n addListener(\n eventName: 'sizeClassChange',\n listenerFunc: (sizeClass: SizeClass) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for changes to the display modes, including a mode ending because\n * the user folded or unfolded the device. Never fires on iOS and web.\n *\n * @since 7.0.0\n */\n addListener(\n eventName: 'displayModeChange',\n listenerFunc: (modes: DisplayModes) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for the device being folded or unfolded. `folding` turns `true` as\n * soon as the hinge starts moving and `false` about half a second after it\n * stops, so an app can pause animations or heavy work while the screen is in\n * motion. Only fires where a hinge angle is available: Android foldables with\n * a hinge sensor, and iPhone Duo on iOS 27.1 or later.\n *\n * @since 8.2.0\n */\n addListener(\n eventName: 'foldingChange',\n listenerFunc: (event: { folding: boolean }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for bar placement changes, such as opening or rotating iPhone Duo.\n * Only fires on iOS 27.1 or later.\n *\n * @since 7.0.0\n */\n addListener(\n eventName: 'barPlacementChange',\n listenerFunc: (placement: BarPlacement) => void,\n ): Promise<PluginListenerHandle>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\nexport interface FoldState {\n /**\n * Posture of the fold. `'closed'` is never reported today: a device shut on\n * its cover display reports `'flat'`.\n *\n * @since 7.0.0\n */\n state: 'flat' | 'half-opened' | 'closed';\n\n /**\n * Whether the fold splits the web view into two areas: `true` when\n * half-opened, or when the hinge has a physical gap.\n *\n * @since 7.0.0\n */\n isSeparating: boolean;\n\n /**\n * How the device is held: `'tabletop'` when half-opened with a horizontal\n * hinge, like a laptop, `'book'` when half-opened with a vertical hinge, and\n * `'flat'` otherwise.\n *\n * @since 7.0.0\n */\n posture: 'flat' | 'tabletop' | 'book';\n\n /**\n * Direction of the hinge relative to the window, so it flips when the device\n * rotates. Omitted when there is no fold.\n *\n * @since 7.0.0\n */\n hingeOrientation?: 'horizontal' | 'vertical';\n\n /**\n * Position of the fold in CSS pixels, relative to the web view. Zero wide (or\n * zero tall) on a seamless fold. Omitted when there is no fold.\n *\n * @since 7.0.0\n */\n hingeBounds?: { x: number; y: number; width: number; height: number };\n\n /**\n * Area of the web view the hinge covers, in CSS pixels. Only present on\n * devices with a physical gap.\n *\n * @since 7.0.0\n */\n occludedBounds?: { x: number; y: number; width: number; height: number };\n\n /**\n * Which display is showing the app, on a device with an inner and an outer\n * display such as iPhone Duo. Reported on iPhone Duo (iOS 27.1 or later) and\n * omitted elsewhere.\n *\n * @since 7.0.0\n */\n activeDisplay?: 'inner' | 'outer';\n\n /**\n * Areas of the web view something covers, in CSS pixels. On Android these are\n * the display cutouts. On iPhone Duo these are the active occlusion regions:\n * the camera in use, and the strip the system keeps for the vertical status\n * bar. Use `getReservedRegions()` when you need to tell them apart. Omitted\n * when there are none.\n *\n * @since 7.0.0\n */\n cameraBounds?: { x: number; y: number; width: number; height: number }[];\n\n /**\n * Space the system asks you to keep clear around the fold, in CSS pixels.\n * `hingeBounds` covers the crease plus these margins, so the crease itself is\n * `hingeBounds` shrunk by them. iPhone Duo reports 20 on each side of a\n * vertical fold. Omitted on Android and where there is no fold.\n *\n * @since 8.2.0\n */\n hingeMargins?: { top: number; right: number; bottom: number; left: number };\n}\n\nexport interface ReservedRegion {\n /**\n * `'division'` is the fold itself. `'occlusion'` is something covering the\n * display, such as a camera or the vertical status bar area.\n *\n * @since 8.2.0\n */\n kind: 'division' | 'occlusion';\n\n /**\n * Whether the region applies right now. An inactive division is a flat fold,\n * and an inactive occlusion is a camera that is not in use.\n *\n * @since 8.2.0\n */\n isActive: boolean;\n\n x: number;\n y: number;\n width: number;\n height: number;\n\n /**\n * Space to keep clear around the region, in CSS pixels.\n *\n * @since 8.2.0\n */\n margins: { top: number; right: number; bottom: number; left: number };\n}\n\n/**\n * How far open the hinge is, as the system sees it. Note that it can lag the\n * angle: iOS keeps reporting `'closed'` for a moment after the phone opens.\n *\n * @since 8.2.0\n */\nexport type HingeStatus = 'closed' | 'partiallyOpen' | 'fullyOpen';\n\nexport interface SizeClass {\n /**\n * Width size class of the window: `'compact'` on a phone and on the outer\n * display of a foldable, `'regular'` on the inner display, tablets and wide\n * windows. On Android and web `'regular'` starts at 600 CSS pixels.\n *\n * @since 7.0.0\n */\n horizontal: 'compact' | 'regular';\n\n /**\n * Height size class of the window: `'compact'` on a phone in landscape. On\n * Android and web `'regular'` starts at 480 CSS pixels.\n *\n * @since 7.0.0\n */\n vertical: 'compact' | 'regular';\n\n /**\n * Material window width class, from the window width in CSS pixels:\n * `'compact'` below 600, `'medium'` below 840, `'expanded'` below 1200,\n * `'large'` below 1600 and `'extraLarge'` from 1600.\n *\n * @since 7.0.0\n */\n widthClass: 'compact' | 'medium' | 'expanded' | 'large' | 'extraLarge';\n\n /**\n * Material window height class, from the window height in CSS pixels:\n * `'compact'` below 480, `'medium'` below 900 and `'expanded'` from 900.\n *\n * @since 7.0.0\n */\n heightClass: 'compact' | 'medium' | 'expanded';\n}\n\nexport type DisplayModeStatus = 'unsupported' | 'unavailable' | 'available' | 'active';\n\nexport interface DisplayModes {\n /**\n * Rear display mode moves the app to the outer display, so people can frame a\n * photo with the rear cameras. Only on Android foldables that offer it.\n *\n * @since 7.0.0\n */\n rearDisplay: DisplayModeStatus;\n\n /**\n * Dual-screen mode shows a second page on the outer display while the app\n * stays on the inner one. Only on Android foldables that offer it.\n *\n * @since 7.0.0\n */\n dualScreen: DisplayModeStatus;\n}\n\nexport interface BarPlacement {\n /**\n * The edge iPhone Duo moves tab bars and toolbars to when it lays them out\n * vertically: `'leading'` or `'trailing'` in the reading direction, so\n * `'leading'` is the left edge in left-to-right languages. `null` when bars\n * stay horizontal, and always on Android, web and iOS before 27.1. An app\n * built with an older Xcode still gets the edge: it is inferred from the\n * safe-area inset the bar leaves, since the trait that names it is missing.\n *\n * @since 7.0.0\n */\n verticalBarEdge: 'leading' | 'trailing' | null;\n}\n\nexport interface FoldablePolyfillOptions {\n /**\n * Take over hiding Ionic's tab bar while the keyboard is open. iPhone Duo\n * reports a keyboard on every fold, and Ionic waits for the window to return\n * to the height it had when the keyboard first opened, so its tab bar can\n * disappear until the device is folded back. With this on, the keyboard events\n * never reach Ionic, and a `foldable-keyboard-open` class on `<html>` marks\n * when a text field really has the keyboard. `ionic-tabs.css` hides the tab\n * bar with it.\n *\n * @default false\n * @since 8.0.2\n */\n ionicKeyboard?: boolean;\n}\n\nexport interface FoldablePlugin {\n /**\n * Whether the device has a fold at all, and whether it can be propped half\n * open like a laptop. Both `false` on web, and on iOS except on iPhone Duo\n * (iOS 27.1 or later).\n *\n * @since 7.0.0\n */\n isDeviceFoldable(): Promise<{ foldable: boolean; supportsTabletop: boolean }>;\n\n /**\n * Read the current fold state. Resolves to\n * `{ state: 'flat', isSeparating: false, posture: 'flat' }` when there is no\n * fold information.\n *\n * @since 7.0.0\n */\n getFoldState(): Promise<FoldState>;\n\n /**\n * Read the angle between the two halves of the device, in degrees: `0` when\n * closed, `180` when flat. Resolves to `{ angle: null }` on devices without a\n * hinge angle sensor, on web, and on iOS before 27.1.\n *\n * @since 7.0.0\n */\n getHingeAngle(): Promise<{ angle: number | null; status: HingeStatus | null }>;\n\n /**\n * Read every region the system reserves on this display: the fold and\n * anything covering the screen, active or not. iPhone Duo reports the fold,\n * the vertical status bar area and the under-display camera. Resolves to an\n * empty list on Android, on web and on iOS before 27.1.\n *\n * @since 8.2.0\n */\n getReservedRegions(): Promise<{ regions: ReservedRegion[] }>;\n\n /**\n * Choose whether iPhone Duo may move this app's bars to the side of the\n * display. `'disabled'` keeps everything horizontal, including the status\n * bar. Resolves to `{ applied: false }` when the app does not use\n * `FoldableBridgeViewController`, on Android, on web and on iOS before 27.1.\n * See the iPhone Duo guide for the one-line storyboard change.\n *\n * @since 8.2.0\n */\n setVerticalBarBehavior(options: { behavior: 'automatic' | 'disabled' }): Promise<{ applied: boolean }>;\n\n /**\n * Read the window's size classes: Apple's compact and regular, the signal its\n * iPhone Duo guidelines recommend for telling the outer display from the inner\n * one, and Material's width and height classes. On iOS `horizontal` and\n * `vertical` are UIKit's size classes; everything else comes from the window\n * size.\n *\n * @since 7.0.0\n */\n getSizeClass(): Promise<SizeClass>;\n\n /**\n * Read which of the foldable display modes the device offers right now. Both\n * are `'unsupported'` on iOS and web.\n *\n * @since 7.0.0\n */\n getDisplayModes(): Promise<DisplayModes>;\n\n /**\n * Read where native bars go. Native tab bars and toolbars move to the side of\n * the display on iPhone Duo, but HTML ones stay put, so use this to move your\n * own tab bar too.\n *\n * @since 7.0.0\n */\n getBarPlacement(): Promise<BarPlacement>;\n\n /**\n * Move the app to the outer display. Android asks the user to confirm first,\n * and the promise resolves once the app has moved. Rejects when rear display\n * mode is not `'available'`. Only on Android.\n *\n * @since 7.0.0\n */\n startRearDisplay(): Promise<void>;\n\n /**\n * Move the app back to the inner display.\n *\n * @since 7.0.0\n */\n stopRearDisplay(): Promise<void>;\n\n /**\n * Show a page on the outer display while the app stays on the inner one. A\n * relative `url` resolves against the app's own URL, so `'cover.html'` loads a\n * page bundled with the app. The page runs in its own web view without access\n * to Capacitor plugins. Calling this again while dual-screen mode is active\n * replaces the page. Only on Android.\n *\n * @since 7.0.0\n */\n startDualScreen(options: { url: string }): Promise<void>;\n\n /**\n * Close the page on the outer display.\n *\n * @since 7.0.0\n */\n stopDualScreen(): Promise<void>;\n\n /**\n * Listen for fold state changes. On a foldable this also fires when the\n * device rotates, because `hingeOrientation` and `hingeBounds` rotate with\n * the window.\n *\n * @since 7.0.0\n */\n addListener(eventName: 'foldStateChange', listenerFunc: (state: FoldState) => void): Promise<PluginListenerHandle>;\n\n /**\n * Listen for hinge angle changes. On Android the hinge sensor only runs while\n * at least one of these listeners is registered. On iOS it fires on iPhone\n * Duo (iOS 27.1 or later). Never fires on web.\n *\n * @since 7.0.0\n */\n addListener(\n eventName: 'hingeAngleChange',\n listenerFunc: (event: { angle: number }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for size class changes, such as unfolding the device, rotating it or\n * resizing the window. On iOS this fires when UIKit's size classes change\n * (iOS 17 or later) or the device rotates, so a resize that keeps the same\n * size classes may not update `widthClass` and `heightClass` until then.\n *\n * @since 7.0.0\n */\n addListener(\n eventName: 'sizeClassChange',\n listenerFunc: (sizeClass: SizeClass) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for changes to the display modes, including a mode ending because\n * the user folded or unfolded the device. Never fires on iOS and web.\n *\n * @since 7.0.0\n */\n addListener(\n eventName: 'displayModeChange',\n listenerFunc: (modes: DisplayModes) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for the device being folded or unfolded. `folding` turns `true` as\n * soon as the hinge starts moving and `false` about half a second after it\n * stops, so an app can pause animations or heavy work while the screen is in\n * motion. Only fires where a hinge angle is available: Android foldables with\n * a hinge sensor, and iPhone Duo on iOS 27.1 or later.\n *\n * @since 8.2.0\n */\n addListener(\n eventName: 'foldingChange',\n listenerFunc: (event: { folding: boolean }) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for bar placement changes, such as opening or rotating iPhone Duo.\n * Only fires on iOS 27.1 or later.\n *\n * @since 7.0.0\n */\n addListener(\n eventName: 'barPlacementChange',\n listenerFunc: (placement: BarPlacement) => void,\n ): Promise<PluginListenerHandle>;\n}\n"]}
@@ -52,3 +52,12 @@ html.ios:not(.vertical-bars-leading):not(.vertical-bars-trailing) ion-tab-button
52
52
  background: rgba(255, 255, 255, 0.1);
53
53
  }
54
54
  }
55
+
56
+ @media (prefers-reduced-transparency: reduce), (prefers-contrast: more), (forced-colors: active) {
57
+ html.ios ion-tab-bar {
58
+ background: Canvas;
59
+ -webkit-backdrop-filter: none;
60
+ backdrop-filter: none;
61
+ border: 1px solid CanvasText;
62
+ }
63
+ }
package/ionic-tabs.css CHANGED
@@ -4,7 +4,7 @@
4
4
  bottom: var(--vertical-tab-bar-bottom, 24px);
5
5
  z-index: 10;
6
6
  flex-direction: column;
7
- width: 45px;
7
+ width: 52px;
8
8
  height: auto;
9
9
  padding: 3px 2px;
10
10
  border: none;
@@ -13,6 +13,7 @@
13
13
  background: rgba(255, 255, 255, 0.78);
14
14
  -webkit-backdrop-filter: blur(12px) saturate(180%);
15
15
  backdrop-filter: blur(12px) saturate(180%);
16
+ --color-selected: #0071e3;
16
17
  box-shadow:
17
18
  inset 0 0 0 0.5px rgba(0, 0, 0, 0.2),
18
19
  inset 0 0 8px rgba(0, 0, 0, 0.04),
@@ -20,11 +21,11 @@
20
21
  }
21
22
 
22
23
  .vertical-bars-leading ion-tab-bar {
23
- left: 24px;
24
+ inset-inline-start: 24px;
24
25
  }
25
26
 
26
27
  .vertical-bars-trailing ion-tab-bar {
27
- right: 24px;
28
+ inset-inline-end: 24px;
28
29
  }
29
30
 
30
31
  .vertical-bars-leading ion-tab-button,
@@ -37,14 +38,27 @@
37
38
  --padding-end: 0;
38
39
  flex: 0 0 auto;
39
40
  width: 100%;
41
+ min-width: 44px;
40
42
  max-width: none;
41
43
  height: 54px;
44
+ min-height: 48px;
42
45
  border-radius: 22px;
43
46
  }
44
47
 
48
+ /* Hidden to the eye, still there for VoiceOver and TalkBack: `display: none`
49
+ would take the tab's name out of the accessibility tree. */
45
50
  .vertical-bars-leading ion-tab-button ion-label,
46
51
  .vertical-bars-trailing ion-tab-button ion-label {
47
- display: none;
52
+ position: absolute;
53
+ width: 1px;
54
+ height: 1px;
55
+ margin: -1px;
56
+ padding: 0;
57
+ border: 0;
58
+ overflow: hidden;
59
+ clip: rect(0 0 0 0);
60
+ clip-path: inset(50%);
61
+ white-space: nowrap;
48
62
  }
49
63
 
50
64
  .vertical-bars-leading ion-tab-button.tab-selected,
@@ -85,3 +99,15 @@ ion-tab-bar[hidden] {
85
99
  background: rgba(255, 255, 255, 0.1);
86
100
  }
87
101
  }
102
+
103
+ /* Reduced transparency, higher contrast, forced colours: drop the blur and
104
+ give the pill an opaque background so the icons stay legible. */
105
+ @media (prefers-reduced-transparency: reduce), (prefers-contrast: more), (forced-colors: active) {
106
+ .vertical-bars-leading ion-tab-bar,
107
+ .vertical-bars-trailing ion-tab-bar {
108
+ background: Canvas;
109
+ -webkit-backdrop-filter: none;
110
+ backdrop-filter: none;
111
+ border: 1px solid CanvasText;
112
+ }
113
+ }
@@ -127,8 +127,9 @@ struct UnsupportedFoldProvider: FoldProvider {
127
127
  ]
128
128
  }
129
129
 
130
- @objc public func barPlacement(in traits: UITraitCollection) -> [String: Any] {
131
- return ["verticalBarEdge": verticalBarEdge(of: traits).map { $0 as Any } ?? NSNull()]
130
+ @objc public func barPlacement(in traits: UITraitCollection, view: UIView? = nil) -> [String: Any] {
131
+ let edge = verticalBarEdge(of: traits) ?? view.flatMap(verticalBarEdge(inferredFrom:))
132
+ return ["verticalBarEdge": edge.map { $0 as Any } ?? NSNull()]
132
133
  }
133
134
 
134
135
  private func verticalBarEdge(of traits: UITraitCollection) -> String? {
@@ -144,6 +145,24 @@ struct UnsupportedFoldProvider: FoldProvider {
144
145
  return nil
145
146
  }
146
147
 
148
+ private func verticalBarEdge(inferredFrom view: UIView) -> String? {
149
+ #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst)
150
+ return nil
151
+ #else
152
+ guard #available(iOS 27.1, *) else { return nil }
153
+
154
+ view.layoutIfNeeded()
155
+ let insets = view.safeAreaInsets
156
+ let rightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
157
+
158
+ if insets.right >= barInsetThreshold { return rightToLeft ? "leading" : "trailing" }
159
+ if insets.left >= barInsetThreshold { return rightToLeft ? "trailing" : "leading" }
160
+ return nil
161
+ #endif
162
+ }
163
+
164
+ private let barInsetThreshold: CGFloat = 70
165
+
147
166
  public func observeChanges(in view: UIView, onChange: @escaping () -> Void) {
148
167
  provider.observe(view, onChange: onChange)
149
168
  }
@@ -128,7 +128,10 @@ public class FoldablePlugin: CAPPlugin, CAPBridgedPlugin {
128
128
  }
129
129
 
130
130
  private func currentBarPlacement() -> [String: Any] {
131
- return implementation.barPlacement(in: bridge?.viewController?.traitCollection ?? UITraitCollection.current)
131
+ return implementation.barPlacement(
132
+ in: bridge?.viewController?.traitCollection ?? UITraitCollection.current,
133
+ view: bridge?.webView
134
+ )
132
135
  }
133
136
 
134
137
  private func currentSizeClass() -> [String: String] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erkamyaman/capacitor-foldable",
3
- "version": "8.3.0",
3
+ "version": "8.3.2",
4
4
  "description": "Fold-state and dual-display APIs for foldable devices in Capacitor apps",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",
@@ -6,6 +6,7 @@ metadata:
6
6
  author: erkamyaman
7
7
  version: '0.1'
8
8
  status: beta
9
+ checked: 2026-09, against Xcode 27.1, Capacitor 8.5, Play target API 36
9
10
  ---
10
11
 
11
12
  # Capacitor Foldable
@@ -21,7 +22,7 @@ npm install @erkamyaman/capacitor-foldable
21
22
  npx cap sync
22
23
  ```
23
24
 
24
- Capacitor 8 uses the current major. Capacitor 7 apps install `@erkamyaman/capacitor-foldable@7`, which reports the fold on Android and size classes everywhere, but has no iPhone Duo support: a Capacitor 7 app built with Xcode 27 crashes at launch, because iOS 27 needs the scene lifecycle that Capacitor 8.5 and later adopted. The 7.x line also predates the `--fold-*` variables (8.1.0) and the Ionic tab stylesheets, so the CSS below needs Capacitor 8.
25
+ Capacitor 8 uses the current major. **If you are on Capacitor 7 and building with Xcode 27, upgrade to 8.5 or later before anything else**: those apps crash at launch, because iOS 27 needs the scene lifecycle that only 8.5 adopted, and a crash on launch is an automatic App Review rejection. Capacitor 7 apps can install `@erkamyaman/capacitor-foldable@7` and build with Xcode 26, which reports the fold on Android and size classes everywhere but has no iPhone Duo support. That route closes when Apple requires the iOS 27 SDK in April 2027. The 7.x line also predates the `--fold-*` variables (8.1.0) and the Ionic tab stylesheets, so the CSS below needs Capacitor 8.
25
26
 
26
27
  ## Start here: CSS before JavaScript
27
28
 
@@ -33,7 +34,7 @@ import { Foldable, installFoldablePolyfill } from '@erkamyaman/capacitor-foldabl
33
34
  await installFoldablePolyfill();
34
35
  ```
35
36
 
36
- Whenever the device reports a fold, that sets `--fold-left`, `--fold-top`, `--fold-width`, `--fold-height` and `--fold-margin-*` on `<html>`, and adds `fold-vertical` or `fold-horizontal`. It also sets the `vertical-bars-*` classes the Ionic stylesheet needs, and fills in the standard Viewport Segments and Device Posture APIs, which Safari and Android's WebView do not provide.
37
+ Whenever the device reports a fold, that sets `--fold-left`, `--fold-top`, `--fold-width`, `--fold-height` and `--fold-margin-*` on `<html>`, and adds `fold-vertical` or `fold-horizontal`. It also sets the `vertical-bars-*` classes the Ionic stylesheet needs, and fills in the standard Viewport Segments and Device Posture APIs. Android's WebView turns both off, and Safari 27.1 has them behind an off-by-default flag that only Safari can set, so a Capacitor web view gets neither. The polyfill stands aside if it ever finds them already there.
37
38
 
38
39
  Prefer the `--fold-*` variables over the polyfilled segment variables for layout: the segments only split when the fold actually separates the window, which on iPhone Duo means half open, while the fold variables are there when it is open flat too.
39
40
 
@@ -84,4 +85,4 @@ npm run build # or the app's build
84
85
  npx cap sync
85
86
  ```
86
87
 
87
- Then run it open flat, half open and closed, and fold it shut and open again, which suspends and resumes the web view on iOS. On iPhone Duo the posture picks the display, so that covers both screens; on Android the outer display needs rear display mode. [testing.md](references/testing.md) has the commands.
88
+ Then run it open flat, half open and closed, and fold it shut and open again, which suspends and resumes the web view on iOS. Do one pass at the largest system text size and one with VoiceOver or TalkBack on, since a layout that splits at the crease is exactly where reading order and target sizes go wrong. On iPhone Duo the posture picks the display, so that covers both screens; on Android the outer display needs rear display mode. [testing.md](references/testing.md) has the commands.
@@ -33,9 +33,11 @@ await Foldable.startDualScreen({ url: 'cover.html' });
33
33
  await Foldable.stopDualScreen();
34
34
  ```
35
35
 
36
- The URL is loaded in a second web view on the other display, and a relative path resolves against the app's own URL, so the page has to be part of the web build. A `data:` URL is often easier for dynamic content, and is what the plugin's own example app uses.
36
+ **Load only content you control.** The plugin refuses anything that is not a page of your own app or a `data:` URL, and keeps the second web view on your origin once it is loaded, because that view shares the app's cookies and storage. Never pass a URL that came from a server, a deep link, a push payload or a user.
37
37
 
38
- Treat it as a presentation surface: a plain web view with no Capacitor bridge, so no plugin API inside it. Anything it needs goes in the URL, though the page is served through the app's own local server, so same-origin storage is readable there too. Calling `startDualScreen` again rebuilds the page rather than messaging it.
38
+ A `data:` URL is the easier route for dynamic content and is what the plugin's own example uses, but note two things. It gets an **opaque origin**, so `localStorage` and friends throw there and it cannot read the app's storage: put what the page needs into the document. And `encodeURIComponent` is URL encoding, not HTML escaping, so interpolating a name or a message into `data:text/html,...` is an injection into a page that has JavaScript on. Escape it for HTML first, or ship a static page and pass values in the fragment for its own script to insert with `textContent`.
39
+
40
+ Treat it as a presentation surface: a plain web view with no Capacitor bridge, so no plugin API inside it. Calling `startDualScreen` again replaces the page rather than messaging it.
39
41
 
40
42
  Both modes share one window area behind the scenes, and both stop when the activity goes away.
41
43
 
@@ -75,7 +75,7 @@ Apple's compact and regular plus Material's window classes. On iOS the first pai
75
75
  const { verticalBarEdge } = await Foldable.getBarPlacement(); // 'leading' | 'trailing' | null
76
76
  ```
77
77
 
78
- `null` means no vertical bar edge is reported: bars are horizontal, or the platform cannot say, which is always the case on Android, web and iOS before 27.1. On iPhone Duo the bar also arrives as a safe-area inset, so simple layouts need nothing beyond `env(safe-area-inset-*)`. Use this when positioning your own chrome to match. `barPlacementChange` fires when it moves.
78
+ `null` means no vertical bar edge is reported: bars are horizontal, or the platform cannot say, which is always the case on Android, web and iOS before 27.1. An app built with an Xcode older than 27.1 cannot read the trait that names the edge, so the plugin infers it from the safe-area inset the bar leaves. On iPhone Duo the bar also arrives as a safe-area inset, so simple layouts need nothing beyond `env(safe-area-inset-*)`. Use this when positioning your own chrome to match. `barPlacementChange` fires when it moves.
79
79
 
80
80
  ```typescript
81
81
  await Foldable.setVerticalBarBehavior({ behavior: 'disabled' }); // everything horizontal
@@ -107,6 +107,8 @@ await Foldable.stopDualScreen();
107
107
 
108
108
  `startRearDisplay()` shows a system confirmation first, and `startDualScreen()` rejects outright without a `url`. Both accept `available` or `active`.
109
109
 
110
- `displayModeChange` reports both modes together. Relative URLs resolve against the app's own URL, so `cover.html` has to be a page you ship in the web build, which for a bundler means adding it as an extra entry point. A `data:` URL works too, and is the easier route for dynamic content. That page runs in its own web view with no access to Capacitor plugins, and calling `startDualScreen` again replaces it.
110
+ `displayModeChange` reports both modes together. Relative URLs resolve against the app's own URL, so `cover.html` has to be a page you ship in the web build, which for a bundler means adding it as an extra entry point. A `data:` URL works too, and is the easier route for dynamic content.
111
+
112
+ The page must be content you wrote: the plugin rejects any other origin, and `encodeURIComponent` is URL encoding rather than HTML escaping, so escape anything you interpolate into a `data:text/html` document. See [android.md](android.md). That page runs in its own web view with no access to Capacitor plugins, and calling `startDualScreen` again replaces it.
111
113
 
112
114
  Android only; both read as `unsupported` on iOS and web, where the `start` methods reject. See [android.md](android.md).
@@ -118,6 +118,24 @@ const handle = await Foldable.addListener('foldStateChange', apply);
118
118
 
119
119
  `animation-play-state` is not inherited and only affects elements that carry a CSS animation, so put it on the animated element itself. For a chart or anything driven by JavaScript, listen to `foldingChange` and stop redrawing instead. The class appears as the hinge starts moving and goes half a second after it stops, and only on devices that report a hinge angle: an Android foldable without a hinge sensor never gets it.
120
120
 
121
+ ## Accessibility
122
+
123
+ The crease is exactly where reading order and target sizes go wrong, so four rules:
124
+
125
+ - **Keep source order equal to visual order.** The `nth-child` pinning above works only while it is. Re-order the markup rather than moving things visually, or a screen reader reads the pages in the wrong sequence.
126
+ - **Name each page and let the keyboard reach it.** Both panes scroll independently, so give them `role="region"` with an `aria-label`, and `tabindex="0"` on the scroll container.
127
+ - **Use `rem` for your own gutters and insets**, not pixels. Android scales web view text with the system font size while iOS does not unless you opt in, so fixed pixel padding drifts in one direction and never moves in the other. Grid items pinned to a fixed track also need `min-width: 0`, or long words push them into the crease.
128
+ - **Targets stay at least 44 pt, or 48 dp on Android**, which matters most for anything sitting beside the fold.
129
+
130
+ `prefers-reduced-motion` is a separate concern from `.folding`: the class is a device signal that comes and goes with the hinge, so honour the media query independently, and use `animation: none` there rather than pausing on an arbitrary frame.
131
+
132
+ Right to left needs its own arm, because the fold variables are physical viewport coordinates while `:first-child` is not:
133
+
134
+ ```css
135
+ [dir='rtl'] .fold-vertical .reader .page:first-child { left: calc(var(--fold-left) + var(--fold-width)); right: 0; width: auto; }
136
+ [dir='rtl'] .fold-vertical .reader .page:last-child { left: 0; width: var(--fold-left); }
137
+ ```
138
+
121
139
  ## What not to do
122
140
 
123
141
  - Splitting at `50%`. The coordinates are relative to the web view, and on iPhone Duo the band has width.
@@ -45,6 +45,10 @@ readonly mode = signal<'game' | 'meter'>('game');
45
45
 
46
46
  If you do route, `provideIonicAngular({ animated: false })` removes the slide, but be aware it disables every Ionic animation app-wide, including modals and ripples.
47
47
 
48
+ ## Apple's rules for the side bar
49
+
50
+ The vertical bar has an order: primary navigation at the top, then prominent actions. Apple also asks you to prefer a symbol over text there, since anything with a label stays in a horizontal bar. The stylesheet hides the labels visually for that reason but keeps them for VoiceOver, so leave them in your markup.
51
+
48
52
  ## Styling gotcha
49
53
 
50
54
  `ion-tab-bar` ships `contain: strict`, which brings size containment with it. The moment you let either dimension come from its contents, it lays out as if empty and collapses: height in the side pill, width in the iOS 26 variant. Both shipped stylesheets set `contain: none` for that reason.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## iPhone Duo simulator
4
4
 
5
- Needs the iOS 27.1 runtime, which the device type refuses to go below, and Xcode 27.1 to build against the 27.1 SDK. Build against the 27.0 SDK and the app fills more of the inner display but still leaves gaps; build against anything older and it runs in compatibility mode inside a black border with no fold reported. The iOS 27.2 beta runtime does not run iPhone Duo at all, as observed on this beta. In Xcode 27 the simulator lives inside **DeviceHub.app**, not `Simulator.app`.
5
+ Needs the iOS 27.1 runtime, which the device type refuses to go below, and Xcode 27.1 to build against the 27.1 SDK. Build against the 27.0 SDK and the app fills more of the inner display but still leaves gaps; build against anything older and it runs in compatibility mode inside a black border with no fold reported. In Xcode 27 the simulator lives inside **DeviceHub.app**, not `Simulator.app`.
6
6
 
7
7
  ```bash
8
8
  open "$(dirname "$(xcode-select -p)")/Applications/DeviceHub.app"
@@ -28,7 +28,7 @@ hinge -d <udid> get
28
28
 
29
29
  Sweep rather than jumping: an instant change can confuse the simulator's display switching.
30
30
 
31
- Known limitation: taps and key input do not land on the Duo simulator through automation ([XcodeBuildMCP #537](https://github.com/getsentry/XcodeBuildMCP/issues/537)), so scripted UI tests cannot drive it yet. Screenshots work from the terminal with an explicit `--display`; through tooling that cannot pass one, a folded screenshot comes back black because it always targets the inner panel.
31
+ Known limitation: agent and automation tooling does not handle the Duo's two panels yet, and several projects have open bugs about it ([XcodeBuildMCP #537](https://github.com/getsentry/XcodeBuildMCP/issues/537), [claude-code #96941](https://github.com/anthropics/claude-code/issues/96941)). Taps and key input do not land, and some tools keep reporting the closed panel's coordinate space while the phone is open, so scripted UI tests cannot drive it. Work from the terminal instead. Screenshots work from the terminal with an explicit `--display`; through tooling that cannot pass one, a folded screenshot comes back black because it always targets the inner panel.
32
32
 
33
33
  ## Android foldable emulator
34
34