@devitup/capacitor-duo-plugin
v0.1.2
Published
Capability-based iPhone Duo hinge and reserved-region information for Capacitor
Maintainers
Readme
@devitup/capacitor-duo-plugin
A small Capacitor 8 plugin exposing UIKit hinge and reserved-region facts to web applications. Application layout stays in CSS. The package does not inspect device names, screen dimensions, or user agents to identify hardware.
Requirements and installation
- Capacitor 8 (
@capacitor/corepeer dependency). - iOS deployment target 15 or later; building the iOS source requires Xcode 27.1 / iOS 27.1 SDK or newer while these APIs remain beta. At runtime, iOS below 27.1 returns neutral state.
- Android v0.1 returns neutral state, including on foldables. Web also returns neutral state.
The contract describes capabilities and geometry rather than Apple model names, so a later Android foldable implementation can use the same consumer API.
npm install @devitup/capacitor-duo-plugin
npx cap syncThe package contains Swift Package Manager and CocoaPods metadata for iOS, plus a standard Android Capacitor library. Capacitor 8 generates a dependency on the Swift product DevitupCapacitorDuoPlugin, which this package exports. No application configuration or permissions are required.
API
import { Duo } from '@devitup/capacitor-duo-plugin';
const state = await Duo.getState();
if (state.isDuo) {
// Choose an app-specific enhancement for the current Duo-capable hierarchy.
}
const listener = await Duo.addListener('layoutChanged', state => {
// Read current native facts; preserve your existing app/component state.
});
await listener.remove();DuoLayoutState has supported, isDuo, hasHinge, hinge, divisionRegions, occlusionRegions, hasDivisionRegion, divisionActive, divisionAxis, verticalBarEdge, and sequence.
supportedmeans the current iOS runtime and view hierarchy can query the iOS 27.1 APIs. On ordinary iPhones running 27.1 this may betruewhileisDuoisfalse.isDuomeans a hinge callback supplies a hinge or a.divisionreserved region is visible to the WebView probe, including an inactive region. It describes the current layout hierarchy, not a permanent hardware identity. If a hierarchy withholds both signals,isDuois false even on Duo hardware. The lower-level fields remain authoritative.hinge.statusisunknown,closed,partiallyOpen, orfullyOpen.angleRadiansis in radians, following UIKit. Apple determines its update frequency and precision.divisionRegionsare places where content should divide.occlusionRegionsare places obscured by hardware, such as a camera. Both include inactive regions. Each has an opaque session-localid,active, andframe.divisionActiveis true if any division region is active. A flat Duo may haveisDuo: true,hasDivisionRegion: true,divisionActive: false, and a zero-width division. Do not reserve a visible hinge gap in that state.divisionAxisis inferred from a region's long dimension; it isnullfor ambiguous geometry. It describes the line's orientation: a tall narrow division isvertical.verticalBarEdgeis UIKit's preferred logical edge for a vertical bar:'leading','trailing', ornullwhen UIKit reportsunspecified. It is available on iOS 27.1+ and remainsnullon earlier iOS, Android, and web. UIKit can report a preference even when no system vertical bar is currently visible. This is a native layout fact, not an instruction to move app navigation.- Leading and trailing follow the current interface layout direction. In left-to-right layouts they usually correspond to left and right; in right-to-left layouts they reverse. Consumers can position their own controls with CSS logical properties such as
inset-inline-startandinset-inline-end. - The edge and
isDuoare independent. A Duo-capable hierarchy can haveverticalBarEdge: null; a system preference outside Duo does not makeisDuotrue. Safe-area CSSenv()values, division regions, and occlusion regions remain separate inputs to an app's layout. sequenceincreases only when a meaningful native snapshot changes. The native side rounds geometry to 0.01 CSS px and angle to 0.001 radian, suppresses identical snapshots, and coalesces hinge/layout callbacks to about one query per 16.7 ms. A posture change can therefore appear up to one frame later.- Each
layoutChangedcallback already receives the complete current state. Apply that argument directly; do not callDuo.getState()inside every callback. The optional DOM helper callsgetState()once during bootstrap, then uses event payloads.
On unsupported iOS, Android, and web, all flags are false, hinge, divisionAxis, and verticalBarEdge are null, both region lists are empty, and sequence is 0.
const state = await Duo.getState();
if (state.verticalBarEdge === 'trailing') {
// The consuming app may choose to place custom controls
// on the platform-provided trailing vertical-control edge.
}The plugin exposes the native preference and does not prescribe navigation design.
Coordinates
Region frame values are CSS layout-viewport pixels measured from the WebView's upper-left edge. UIKit reports points in the probe's coordinates; the plugin first uses UIView.convert(_:to:) to map them into the WKWebView's bounds. It then checks the WebView bounds against window.innerWidth/innerHeight and visualViewport dimensions, scale, and offsets. UIKit points and CSS pixels map nearly 1:1 for the supported, settled Capacitor viewport (width=device-width, initial-scale=1, viewport-fit=cover), without multiplying by device pixel ratio. A WebView may have any stable rectangular size or orientation, but native content insets, pinch zoom, keyboard-shifted visual viewports, and transformed/offset viewports are outside this coordinate contract. Use CSS safe-area env() values separately.
During orientation, fold, or resize transitions, UIKit and WebKit can briefly report different layout frames. The plugin rejects zero, nonfinite, mismatched, rotated, and out-of-bounds samples as a whole. It retains the last valid state (including its sequence) and retries each frame for about half a second, then every 250 ms until a valid sample arrives or the view detaches. A new layout or hinge update brings the next retry forward. It never publishes a partially mapped region list. Debug builds log a short rejection reason at most once per second; release builds do not log it. If the first sample is invalid, getState() returns the neutral state until the first valid sample.
No ordinary viewport width, orientation, pixel density, or safe-area values are bridged; use CSS, ResizeObserver, visualViewport, and browser APIs for those.
Optional DOM projection
import { installDuoLayout } from '@devitup/capacitor-duo-plugin/dom';
const dispose = await installDuoLayout();
// When the owning app/service is destroyed:
await dispose();The helper sets data-duo, data-duo-supported, data-duo-hinge, data-duo-division, and, when known, data-duo-division-axis on <html>. For the first active division it sets --duo-division-x, --duo-division-y, --duo-division-width, and --duo-division-height.
When UIKit specifies an edge, the helper also sets data-duo-vertical-bar-edge="leading" or "trailing". It removes the attribute when the edge becomes null, on unsupported state, and on disposal.
It also sets data-duo-occlusion="active|inactive" and data-duo-occlusion-count (the full active count). Up to four active occlusions, sorted by x, y, then opaque ID, receive --duo-occlusion-0-x/y/width/height through --duo-occlusion-3-x/y/width/height. The full region list remains available through Duo.getState() and event payloads. The helper removes stale properties when regions disappear, and removes all its attributes/properties on disposal or unsupported state. It subscribes before requesting state and rejects an older snapshot using sequence.
For multiple division regions, use Duo.getState() directly; the CSS variables describe the first active region only.
Sass and CSS
@use '@devitup/capacitor-duo-plugin/scss' as duo;
@include duo.device {
@media (min-width: 700px) {
.app-navigation { /* consumer-chosen wide Duo treatment */ }
}
}
.content {
@include duo.division-active {
/* avoid or split around the real fold */
}
}
@include duo.vertical-bar-trailing {
.custom-control { inset-inline-end: var(--app-control-inline-offset); }
}700px is an application-selected width, not an official Duo breakpoint. Mixins: device, hinge, hinge-active, hinge-fully-open, hinge-partially-open, division-active, division-vertical, division-horizontal, occlusion-active, vertical-bar, vertical-bar-leading, and vertical-bar-trailing. They compile to selectors for the DOM helper and work at the stylesheet root or inside a selector. The vertical-bar mixins add no widths or navigation styles.
For a full-viewport vertical division, a consumer might place its two panes in a grid using the actual geometry:
:root[data-duo-division='active'][data-duo-division-axis='vertical'] .split {
display: grid;
grid-template-columns: var(--duo-division-x) var(--duo-division-width) minmax(0, 1fr);
}
.split > :first-child { grid-column: 1; }
.split > :last-child { grid-column: 3; }Horizontal divisions use --duo-division-y and --duo-division-height in grid-template-rows. Consumers should account for their own container offset and size; the variables are relative to the WebView viewport, not a nested element.
Verification and troubleshooting
Run npm test, npm run build, npm run verify:package, and an Xcode build/test of CapacitorDuoPlugin. If native state stays neutral, verify iOS 27.1, plugin registration after npx cap sync, and that the WebView is attached to a visible scene. An inactive division is expected when flat. Verify viewport meta settings if CSS geometry appears offset. The Duo simulator should be used for manual fold/flat transition checks before shipping; an SDK build alone cannot validate live hinge behavior.
The plugin itself collects or transmits no user data. Its JavaScript evaluation reads only the current layout viewport dimensions to convert native coordinates.
See SDK findings for the exact beta API names and differences from the earlier plan.
The release handoff lists the remaining remote setup and live Duo validation before publishing.
