@webview-native-ui/engine 0.0.1 → 0.0.3

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.
Files changed (47) hide show
  1. package/LICENSE +17 -14
  2. package/README.md +27 -599
  3. package/WebViewNativeUIEngine.podspec +2 -2
  4. package/dist/{scroll-bindings.d.ts → dom/scroll-bindings.d.ts} +1 -1
  5. package/dist/{scroll-placement.d.ts → dom/scroll-placement.d.ts} +1 -1
  6. package/dist/index.d.ts +9 -9
  7. package/dist/index.js +8 -8
  8. package/dist/ionic.d.ts +3 -3
  9. package/dist/ionic.js +1 -1
  10. package/dist/{native-bootstrap.d.ts → native/bootstrap.d.ts} +1 -1
  11. package/dist/{native-bootstrap.js → native/bootstrap.js} +1 -1
  12. package/dist/{bridge.d.ts → native/bridge.d.ts} +1 -1
  13. package/dist/{native-session.d.ts → native/session.d.ts} +3 -2
  14. package/dist/{native-session.js → native/session.js} +8 -7
  15. package/dist/{projection-events.d.ts → projection/events.d.ts} +1 -1
  16. package/dist/{projection-events.js → projection/events.js} +1 -1
  17. package/dist/{web-projection.d.ts → projection/web.d.ts} +5 -3
  18. package/dist/{web-projection.js → projection/web.js} +9 -6
  19. package/dist/types.d.ts +1 -1
  20. package/docs/assets/hero.png +0 -0
  21. package/docs/capacitor-plugin.md +279 -0
  22. package/docs/cordova-plugin.md +198 -0
  23. package/docs/cordova.md +1 -123
  24. package/docs/maintenance.md +52 -0
  25. package/docs/projection.md +340 -0
  26. package/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +6 -14
  27. package/ios/Sources/WebViewNativeUIEngine/NativeUIProjection.swift +6 -3
  28. package/ios/Sources/WebViewNativeUIEngine/Resources/native-ui-engine.js +3 -3
  29. package/package.json +2 -3
  30. package/NOTICE +0 -39
  31. /package/dist/{geometry.d.ts → dom/geometry.d.ts} +0 -0
  32. /package/dist/{geometry.js → dom/geometry.js} +0 -0
  33. /package/dist/{overlays.d.ts → dom/overlays.d.ts} +0 -0
  34. /package/dist/{overlays.js → dom/overlays.js} +0 -0
  35. /package/dist/{scroll-bindings.js → dom/scroll-bindings.js} +0 -0
  36. /package/dist/{scroll-placement.js → dom/scroll-placement.js} +0 -0
  37. /package/dist/{visibility.d.ts → dom/visibility.d.ts} +0 -0
  38. /package/dist/{visibility.js → dom/visibility.js} +0 -0
  39. /package/dist/{bridge.js → native/bridge.js} +0 -0
  40. /package/dist/{runtime.d.ts → native/resource-url.d.ts} +0 -0
  41. /package/dist/{runtime.js → native/resource-url.js} +0 -0
  42. /package/dist/{projection-placement.d.ts → projection/placement.d.ts} +0 -0
  43. /package/dist/{projection-placement.js → projection/placement.js} +0 -0
  44. /package/dist/{web-projection-scope.d.ts → projection/scope.d.ts} +0 -0
  45. /package/dist/{web-projection-scope.js → projection/scope.js} +0 -0
  46. /package/dist/{snapshot-styles.d.ts → projection/styles.d.ts} +0 -0
  47. /package/dist/{snapshot-styles.js → projection/styles.js} +0 -0
@@ -0,0 +1,340 @@
1
+ # Projection behavior and advanced integration
2
+
3
+ Start with the [Capacitor](capacitor-plugin.md) or [Cordova](cordova-plugin.md) guide. This reference explains shared DOM requirements, destination rules and the lower-level APIs for existing renderers.
4
+
5
+ ## Ownership
6
+
7
+ | Engine | Consuming plugin |
8
+ | ------------------------------------------------------------------- | -------------------------------------------------------------------- |
9
+ | DOM measurement, image geometry and supported native scroll binding | Creating, attaching, positioning and sizing native views |
10
+ | Startup, event subscriptions and native draw acknowledgements | Providing the fallback DOM at the correct layer |
11
+ | Prepared image cache, decoding, display and retirement | Reporting appearance changes and the intended projection destination |
12
+ | Native capture and temporary return images | Providing native views and SDK capture eligibility |
13
+ | Image invalidation, token cleanup and observer teardown | Owning business actions and the interactive Web fallback |
14
+
15
+ A DOM slot does not necessarily contain a Web replacement: a UI plugin can restore a button, while an ad plugin may hide its slot. Image handoff is explicit and noninteractive. The engine never forwards clicks through an image or replaces SDK click/impression handling.
16
+
17
+ ## Projection contract
18
+
19
+ A notification specifies **where the surface belongs**, not how to capture it. The Engine chooses the platform-specific handoff without requiring the plugin to keep another copy of projection state.
20
+
21
+ | Intended destination | Required behavior |
22
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `native` | Show the interactive native view at confirmed geometry. During motion, use supported native tracking or a prepared image. |
24
+ | `web` | Keep native content below the covering Web layer by removing its native projection; use a prepared image or the plugin's Web fallback. |
25
+ | `none` | Retire native content, projected images and fallback content. Later update/capture callbacks must not show it again. |
26
+
27
+ Overlay coverage can temporarily turn `native` into `web`; it never overrides `none`. A capture failure does not change the intended destination. `failedFallback: native` is only valid for consumers that maintain native placement themselves, and never overrides explicit `web`, `none`, or overlay coverage.
28
+
29
+ The Engine owns image readiness and restoration acknowledgement. It must not hide native pixels before replacement pixels are ready, nor retire replacement pixels before native restoration succeeds. Position-only motion reuses prepared content; appearance, size and renderer identity changes invalidate it. A missing image uses the declared fallback immediately rather than waiting to capture during a transition.
30
+
31
+ Registration and notification forwarding retain the latest intent across startup and reconnection; they do not replay business events. Destruction retires the registration and its cached images. Notifications are intent, not completion promises: an API that promises completed hiding must await the explicit retirement acknowledgement described under DOM and native surface requirements.
32
+
33
+ An existing renderer that checks CSS visibility can call `getUnprojectedVisibility(element)` from the npm package. It reads current authored/inherited visibility without the Engine's hiding rules, and restores projection ownership before returning. Do not inspect or mutate Engine marker attributes in a consuming plugin. This helper performs a style read and can trigger attribute observers; it does not eliminate measurement or transport costs.
34
+
35
+ ## DOM and native surface requirements
36
+
37
+ `projectionSelector` is required and must resolve to **one existing container when projection starts**, including one retrieved by the plugin's own `querySelector`. The engine does not create a DOM or choose its size, placement, layer or z-index. Keep the container's normal layout and any interactive Web controls; an empty zero-size placeholder is not a usable fallback. It is a fixed scope, not a discovery root.
38
+
39
+ The container does not have to exist at registration time. A missing DOM waits without showing native content; replacing it creates a new binding and retires old images. The engine preserves the container's children, listeners and values. Pass the native view itself through `view`; a group of controls can use their common native container. The engine manages the internal identifier, so no ID list or view dictionary is required. Independent surfaces use separate registrations.
40
+
41
+ A Web Component host can be the destination, for example `projectionSelector: 'native-ad[placement="home"]'`. Give the host a layout box (`display: block` with the intended dimensions); the engine observes insertion, removal and selector attribute changes. The selector runs in the document and does not search inside shadow roots. The fallback content, including shadow content, must inherit the host's `visibility`: do not override it with `visibility: visible`, and reserve the host's `::before` for the projection image. These requirements apply to open and closed shadow roots.
42
+
43
+ For independently moving AdMob slots, retain one registration per loaded native view/surface, use a slot-specific selector and filter the plugin's notifications through `receiveListeners.match` (`match: ["slotKey": slotKey, "loadId": loadId]` / `match = mapOf("slotKey" to slotKey, "loadId" to loadId)`). Dispose the registration when a loaded ad is replaced, even if its host view is reused. Keep SDK loading, impression and click handling in the ad plugin. Supply `canCapture` with its existing capture eligibility checks (for example, test-ad / opaque-background / no-video restrictions); the engine does not grant permission to snapshot an SDK view.
44
+
45
+ For SDK consumers without an equivalent Web fallback, set `failedFallback: .native`
46
+ on iOS or `failedFallback = NativeUIProjection.FailedFallback.NATIVE` on Android.
47
+ The default is `.web` / `WEB`, preserving existing consumers. If automatic motion
48
+ cannot use a prepared image (not ready, unsupported capture, or rejected handoff),
49
+ `native` preserves the live native view and keeps fallback DOM hidden. The consumer
50
+ must continue positioning that view; this option does not create native scroll
51
+ tracking on Android. Explicit `web`, `none`, and overlay coverage take precedence:
52
+ this option never raises a native view above an intentionally covering Web layer.
53
+
54
+ For opaque, software-renderable Android SDK views, use
55
+ `captureMode = NativeUIProjection.CaptureMode.VIEW` together with an explicit
56
+ `failedFallback` policy (`WEB` unless the consumer maintains native placement). This draws only the View's
57
+ pixels with no surrounding padding, so clipping a partially visible ad cannot bake
58
+ in a toolbar or adjacent content. Video, SurfaceView, translucent views, and content
59
+ requiring Window composition must keep the default `COMPOSITED` mode. The consumer
60
+ must provide a matching, clipped DOM host for the full opaque View; arbitrary native
61
+ outline masks, transforms, and custom clipping are not reproduced.
62
+
63
+ VIEW pixels are cached by native View identity, dimensions, and content revision.
64
+ Ordinary scrolling retains the decoded Web image and updates placement without
65
+ capture, PNG encoding, or image decode. The image covers the host border box at
66
+ local origin (0, 0), independently of native placement acknowledgment latency.
67
+ Opaque images remain painted beneath the live native View between scrolls.
68
+ Before hiding that View, the engine waits for WebView visual readiness and a draw;
69
+ VIEW creates no native entry or return image copies. Return acknowledgment waits
70
+ across the native draw and the following animation frame. A generation check prevents
71
+ a delayed cover callback from hiding content after restoration or suspension.
72
+
73
+ The native Bitmap is recycled after PNG encoding in VIEW mode; the PNG and decoded
74
+ Web image remain cached. Plugin update notifications advance the content revision:
75
+ notify an update whenever content changes without replacing the View. Replacing the
76
+ registration, WebView navigation, reset, or close retires the cache. Encoding stays
77
+ off the UI thread; View drawing and cache ownership stay on it, with the existing
78
+ 8-million-pixel cap.
79
+
80
+ COMPOSITED retains native handoff copies for translucent content. Entry copies wait
81
+ for WebView visual readiness and a draw before retirement. If the 500 ms entry
82
+ deadline expires, the native View is restored before the copy is removed, and a late
83
+ paint acknowledgment is rejected.
84
+
85
+ Android VIEW consumers can read `isProjecting` on the UI thread after a successful
86
+ cover to defer same-size native placement work. Keep only the latest accepted placement;
87
+ continue validating sequence/generation, and process hide, resize and teardown immediately.
88
+ Supply `beforeNativeReveal` to apply that pending placement synchronously before normal
89
+ restoration, failure recovery, or active-image release reveals native pixels. The engine
90
+ clears `isProjecting` before invoking the callback, so applying placement must not defer
91
+ again. The callback may run without pending work and must be idempotent. Clear consumer
92
+ pending work on hide/destroy and validate the loaded View identity before applying it.
93
+ The callback does not replace content/size invalidation or JavaScript geometry tracking.
94
+
95
+ On Android, consumers whose asynchronous hide method promises that all pixels have
96
+ disappeared must call `projection.hide { completed -> ... }` and resolve only when
97
+ `completed` is true. Native handoff copies disappear immediately; completion waits
98
+ for synchronous Web-proxy retirement. A timeout reports failure, not success.
99
+ Call `projection.show()` when presenting again, in addition to normal plugin events;
100
+ this also preserves presentation intent if event subscription is still starting.
101
+
102
+ Scroll observation follows composed-tree ancestors, including assigned slots.
103
+ Non-composed scroll events inside an Ionic shadow tree are observed at their source;
104
+ applications do not add an extra listener or dispatch synthetic document events.
105
+ Listeners follow host reparenting and slot reassignment and are removed on teardown.
106
+
107
+ Scrolling is internal: iOS uses a uniquely matched native scroll binding when available; otherwise, including Android, scrolling temporarily uses the prepared image and restores native content after motion settles. Automatic scroll handling never overrides explicit `web` or `none`. Unsupported capture or geometry keeps the normal DOM fallback; it does not wait for a new capture on the transition path. The latest explicit destination survives WebView reload, and old-document replies cannot complete a new document's requests.
108
+
109
+ By default, the internal channel is limited to the main frame on Capacitor's localhost origins. Pass the application's exact `allowedOrigins` for a different configured origin. Android requires WebView's `WEB_MESSAGE_LISTENER` and `DOCUMENT_START_SCRIPT` features; registration fails explicitly on unsupported WebView implementations. Do not use wildcard origins for remotely navigable WebViews.
110
+
111
+ ## Ionic overlay support (optional)
112
+
113
+ For an Ionic application, call this once during application startup. When calling it directly from the application, install `@webview-native-ui/engine` as a direct application dependency:
114
+
115
+ ```ts
116
+ import { enableSupportIonicOverlays } from '@webview-native-ui/engine/ionic';
117
+
118
+ enableSupportIonicOverlays();
119
+ ```
120
+
121
+ Plugin maintainers can include this snippet in their application setup instructions, or call it inside an existing JS initializer. `@rdlabo/capacitor-native-ui` already calls the same initializer internally; its users need no additional call. Non-Ionic applications omit it. Native registration alone does not install Ionic event listeners.
122
+
123
+ One monitor is shared per document, including between the npm module and the runtime injected by Swift/Android. Repeated initialization does not add listeners or erase explicit configuration. Destroying one projection removes only its subscription; it does not stop the monitor used by other plugins. The monitor lives with the document.
124
+
125
+ The default covers Modal, Popover, Alert, Action Sheet, Loading, Picker, Toast and Menu lifecycle events. It compares each projection's DOM with the active overlay stack: content inside the front modal stays native, while covered content temporarily uses its prepared image. Toasts and inactive sheet backdrops do not cover a surface. Nested overlays keep it covered until the remaining stack permits restoration. Missing images retain the normal Web fallback. This temporary coverage never overwrites a plugin's explicit `web` or `none` destination. Projections registered later read the current state.
126
+
127
+ Native Popover ownership is supplied by the Native UI vertical-bars renderer. The shared monitor does not treat its source DOM as a covering Web overlay. If native presentation fails, the renderer releases ownership and normal DOM coverage resumes.
128
+
129
+ Optional settings are document-wide:
130
+
131
+ ```ts
132
+ enableSupportIonicOverlays({
133
+ exclude: '[data-nonblocking-overlay]',
134
+ additional: [
135
+ {
136
+ selector: 'app-dialog',
137
+ events: {
138
+ willPresent: 'appDialogWillPresent',
139
+ didDismiss: 'appDialogDidDismiss',
140
+ },
141
+ },
142
+ ],
143
+ });
144
+ ```
145
+
146
+ Use `covers(overlay, target)` only when a registered DOM projection needs a custom coverage rule; return `undefined` for the default, or a boolean to override it. Exclusions and explicit native-window ownership take precedence. The standard relationship uses overlay order, DOM containment and backdrop state, rather than attempting to interpret arbitrary CSS stacking contexts. Configure unusual layouts explicitly.
147
+
148
+ ## Low-level Web projection
149
+
150
+ A scope binds one existing Web fallback element for its lifetime. Obtain that element from your plugin's DOM; the engine does not create a replacement host or choose a page, stacking order or z-index. A different fallback element requires a new scope. Its Document is derived from `element.ownerDocument`.
151
+
152
+ ```ts
153
+ import { createWebProjectionScope, createWebProjection } from '@webview-native-ui/engine';
154
+
155
+ const fallback = container.querySelector<HTMLElement>('[data-web-fallback]');
156
+ if (!fallback) throw new Error('Web fallback is missing');
157
+ const scope = createWebProjectionScope(fallback, 'my-plugin');
158
+ const projection = await createWebProjection(
159
+ scope,
160
+ {
161
+ send: (request) => plugin.snapshot(request),
162
+ listen: (listener) => plugin.addListener('snapshotInteraction', listener),
163
+ },
164
+ {
165
+ wake: () => schedulePluginUpdate(),
166
+ blocked: () => overlayIsActive(),
167
+ revision: () => lastSentRevision,
168
+ },
169
+ );
170
+ ```
171
+
172
+ The container, bridge and update callbacks above belong to the consuming plugin. No `document`, `target`, `layer`, CSS selector or layer resolver is passed to the projection manager.
173
+
174
+ 1. After an accepted native update, call `projection.consider(revision, sources)`. Sources associate the native IDs and viewport frames with their source DOM; they need not be inside the fallback element. Images are captured and decoded in advance.
175
+ 2. Call `projection.changed()` for appearance changes, or `projection.changed(scrollElement)` to invalidate scroll geometry. Layout updates go through `consider`; use `invalidate()` for other geometry changes.
176
+ 3. Before covering Native with a Web overlay, call `projection.begin()`. It displays already prepared pixels at the fixed fallback. No new capture or decode starts here. If capture is unavailable, the plugin keeps its ordinary Web fallback.
177
+ 4. Exclude `projection.owners` from normal Native updates while the image owns those sources. `visibleOwners` keeps their return pixels while Native is being restored; `preparedOwners` identifies sources with prepared images. These arrays are read-only snapshots.
178
+ 5. Call `projection.end()`, send the normal native update, then call `projection.updated(snapshotToken, acceptedIDs, revision)`. A newer acknowledgement containing all captured IDs retires the image.
179
+ 6. Call `projection.destroy()` when disposing the surface. It releases only its own images and listener; the plugin owns final Native reset and its DOM.
180
+
181
+ The engine uses a noninteractive, empty CSS `::before` pseudo-element for the prepared image. Supply a container that supports it, such as an existing `div`, `button` or custom element. Replaced/void elements such as `img`, `input` and `video` are rejected when creating the scope; use their existing fallback container. It does not clone, replace, remove or set `inert`/`aria-hidden` on the existing fallback or its children. Removing its temporary image styles restores the same live DOM, including application changes made during projection. Event listeners and form values are preserved. The renderer may move that same element between parents; it must not replace the scope's element or let two managers control it simultaneously.
182
+
183
+ ### Notifications from the existing plugin
184
+
185
+ The low-level manager can subscribe to the existing plugin's events through `options.events`:
186
+
187
+ ```ts
188
+ events: {
189
+ addListener: (name, listener) => plugin.addListener(name, listener),
190
+ update: ['setColors', 'setTabs', 'tabSelected'],
191
+ projection: {
192
+ native: ['shown'],
193
+ web: ['transitionStarted'],
194
+ none: ['hidden'],
195
+ },
196
+ }
197
+ ```
198
+
199
+ These are **notifications**, not intercepted method names. The plugin emits update events after its native changes succeed. A Web projection event must arrive before movement starts; it uses the previously prepared image. A native event requests restoration, but the image remains until the normal renderer acknowledgement confirms that Native is ready. A hidden event follows the plugin's native hide operation and retires the image; it does not invoke `hide()` or change application-owned fallback DOM. Appearance updates preserve the requested destination, including `none`.
200
+
201
+ For plugins with several surfaces, provide `accepts(event)` to select this scope's stable surface identity. Events shared by `update` and a destination are subscribed once and processed together. Conflicting destinations for the same event are rejected before subscription. Destroying the manager removes all its subscriptions, including registrations that resolve late after initialization fails. No public plugin methods are wrapped or replayed.
202
+
203
+ The manager converts private native file URLs with Capacitor internally. Plugin authors do not supply a URL resolver.
204
+
205
+ This section describes the lower-level manager for renderers that already own batch reconciliation, such as the Ionic adapter. Prefer the native registration in the plugin guides for a new integration; it owns startup, measurements and draw acknowledgements.
206
+
207
+ ### Multiple fallbacks in one Native transaction
208
+
209
+ Use `createWebProjectionBatch(transport, options)` when several fallback elements must switch together. It uses the same image engine, one listener and one capture/cover/restore token. Pass `{ scope, element, control, ... }` for each entry to `batch.consider(revision, sources)`. Several native IDs may share one scope, such as a button group.
210
+
211
+ The batch derives its Document from the first nonempty `consider`; all subsequent scopes and sources must belong to that Document. Before this first update, `begin()` does nothing and `destroy()` is safe. A changed fallback requires a new scope. Scope identity participates in cache invalidation; an active presentation retains its captured scopes until restoration finishes.
212
+
213
+ `WebProjectionRequest` is an action-discriminated union. `prepare` requires `revision`, `viewportWidth` and `ids`; `cover` requires `token`; `restore` requires `token`, `viewportWidth` and destination `parts`; `painted`, `restored` and `release` require `token`; `reset` takes no payload. Empty preparation results and `{ shown: false }` mean the plugin should use its normal Web fallback. Use the plugin's existing ordered bridge; the engine does not retry user actions.
214
+
215
+ ### Native handoff on both platforms
216
+
217
+ Call all view and snapshot lifecycle APIs on the main/UI thread. Native input frames use **points relative to the WebView on iOS** and **physical pixels relative to the reference view on Android**. `viewportWidth` is the Web viewport width in CSS pixels. Prepared parts sent to JavaScript are converted to CSS pixels.
218
+
219
+ On both platforms, `cover` installs the prepared entry image **before hiding the supplied live views**, within the same UI turn. Its boolean result reports whether this was possible; failure leaves visibility unchanged. Supply the actual views for every captured component. Do not separately hide views or create another entry copy.
220
+
221
+ ```swift
222
+ let shown = snapshots.cover(token, in: webView, views: renderedViews, transient: movingSurface)
223
+ ```
224
+
225
+ ```kotlin
226
+ val shown = snapshots.cover(token, activity.window, webView, renderedViews, transient = movingSurface)
227
+ ```
228
+
229
+ `transient: true` is for a moving foreground surface: it does not replace the background's `coveringToken`. Retire the entry copy with `retireCopy(token)` after Web image readiness. Before applying a normal native update, undo the engine's visibility changes with iOS `restoreControls()` / Android `restoreViews()`. For return, `restore` installs the cached return image, the plugin renders underneath it, and `retireCopy(token, entry: false)` retires it after the corresponding acknowledgement/readiness. These platform-native names remain idiomatic; the lifecycle is the same. `cover` is an entry operation, not a method to repeat on each normal update.
230
+
231
+ Token cleanup uses `release(token)`; owner teardown uses `reset()` (and Android `close()` when disposing its encoder). A surface manager must not reset another surface's captures. Web fallback remains the renderer's responsibility when a capture or native placement is unavailable.
232
+
233
+ The optional `eligible(source)` and `current(sources)` callbacks keep framework-specific surface selection outside the engine. `transient: true` supports a moving surface whose return image must be rebased. Arbitrary CSS transforms are not supported by native scroll tracking; such movement can use this image path.
234
+
235
+ The image transport accepts `WebProjectionRequest` and returns prepared token/parts or `{ shown: false }`. Parts use local `file:` URLs and CSS-pixel viewport coordinates. Requests use the plugin's existing ordered bridge:
236
+
237
+ | Action | Native responsibility |
238
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
239
+ | `prepare` | Validate the current revision and IDs, then `NativeUICoveredSnapshots.prepare` using actual rendered frames. |
240
+ | `cover` | Validate the token and complete native surface ownership; create the entry copy and hide the associated native views. Return `shown: false` on failure. |
241
+ | `painted` | Retire only that token's entry copy. |
242
+ | `restore` | Rebase the prepared return copy to supplied frames. Never capture here. |
243
+ | `restored` | Retire only that token's return copy after the native update ACK and Web paint boundary. |
244
+ | `release` | Release the named token, files and associated temporary views. |
245
+ | `reset` | Owner teardown only; release all native capture resources. |
246
+
247
+ Use `NativeUICoveredSnapshots.observe` to notify the JS listener when interaction starts/ends. On iOS, captures rasterize the visible window with `drawHierarchy(in:afterScreenUpdates:)`, including the background under the clipped native component. Screen snapshot views are display replicas and are not used as bitmap sources. UIKit rasterization can differ from the live compositor for materials such as Liquid Glass; validate the appearance on physical devices with the backgrounds your plugin supports. Cache validity therefore includes layout and the plugin's visual-change notifications. The engine does not capture continuously or guarantee every possible renderer can be captured.
248
+
249
+ ### Temporary storage and application permissions
250
+
251
+ Prepared images stay in the app's private temporary storage. iOS uses `FileManager.default.temporaryDirectory`. On Android, construct `NativeUISnapshots` with a directory inside `context.cacheDir`; do not use shared or external storage. Neither platform needs a photo-library or storage permission prompt for this path. No Capacitor Filesystem plugin is required. See [Apple's sandbox directories](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html) and [Android's app-specific storage](https://developer.android.com/training/data-storage/app-specific).
252
+
253
+ `Capacitor.convertFileSrc` maps the returned file URL to the existing WebView local server. It grants no filesystem permission and does not upload the image. If the application sets a Content Security Policy, its `img-src` must allow the local app origin used by that URL; do not enable broad `file:` access or unrelated WebView file-access settings.
254
+
255
+ `release(token)` and owner teardown remove owned snapshot files. Abrupt process termination can leave temporary images until the operating system clears the cache or the app is uninstalled; immediate deletion after a crash is not guaranteed. Keep this storage private and treat it as disposable, not persistent application data.
256
+
257
+ ## Native scroll tracking
258
+
259
+ `readScrollPlacement(element, owner, identify)` measures one untransformed overflow owner. `createScrollBindings()` validates reuse and detects movement while acquisition is awaiting ACK. Keep `reuse` out of layout-deduplication signatures; it describes acknowledgement state, not layout.
260
+
261
+ On iOS, `NativeUIScrollBindings.replace` groups sources by owner and binds only an unambiguous matching `UIScrollView`. `update()` applies positions and clipping without rebuilding content. `NativeUIClippedView` preserves full content dimensions while restricting visible pixels and hit testing.
262
+
263
+ An established binding rejected by native loses ownership and may be freshly acquired through the **same** update/ACK path. A failed fresh acquisition stays on the fallback until a relevant layout/scroll-end event. Never add an immediate retry loop or replay user actions. A frame/props change does not recalibrate an established coordinate origin.
264
+
265
+ This uses WKWebView's internal overflow hierarchy without private class names. Missing/ambiguous views, nested scrolling, document scrolling, unsupported transforms, shaped clipping, or a moving unconfirmed owner cannot be bound. Do not claim support solely because a `UIScrollView` exists. A native view may intercept gestures that begin inside it; routing those gestures and SDK accessibility behavior remain renderer-specific.
266
+
267
+ ## Android library
268
+
269
+ Android uses a Gradle Android library, distributed as an AAR through Maven rather than Capacitor plugin registration. The publication coordinates are `io.github.webview-native-ui:engine:<version>`. Build and verify the local Maven publication with:
270
+
271
+ ```sh
272
+ android/gradlew -p android testDebugUnitTest assembleRelease connectedDebugAndroidTest publishReleasePublicationToVerificationRepository
273
+ ```
274
+
275
+ `NativeUIClippedView` is a low-level helper that applies caller-supplied position and clipping without resetting unchanged dimensions. `NativeUIProjection` manages image handoff and restoration; it does not use this helper to lay out the renderer. The consuming plugin owns normal native placement and size updates. Android does not claim WKWebView-style native overflow binding. High-level registrations share a non-consuming window touch observer: down cancels in-flight capture adoption, up/cancel/focus loss permits preparation again, and teardown removes only that registration. Existing callback dispatch and return values are preserved.
276
+
277
+ `NativeUISnapshots` prepares composited PixelCopy images and local PNG files, then covers/restores from that cache. Encoding runs off the UI thread; only one preparation runs per owner. Notify it of visual changes and interaction, and release tokens on teardown. All view/lifecycle calls run on the UI thread. The plugin retains revision, eligibility and fallback policy.
278
+
279
+ After prepared Web pixels are shown or retired, `NativeUIWebReadiness.prepare` checks readiness for a subsequent WebView draw. Its `current` predicate must validate the owning session, source generation and image revision. The temporary native copy covers this asynchronous handoff; overlay startup itself does not wait. Neither bridge acknowledgement nor this callback proves physical display presentation.
280
+
281
+ [AdMob PR #487](https://github.com/capacitor-community/admob/pull/487) demonstrates a persistent image underlay for opaque test ads. That policy is not enabled globally: translucent UI would be composited twice, and video/SDK interactions require renderer-specific decisions. Never synthesize clicks through an image. The Native UI consumer uses the engine on both iOS and Android. Android acceptance tests exercise the real Capacitor bridge and translucent Compose content. Other SDK consumers still require their own integration and acceptance testing.
282
+
283
+ ### Translucent Android surfaces
284
+
285
+ Capture the composited window pixels, including the background, instead of drawing the translucent view onto an empty bitmap. Keep the prepared Web image decoded but hidden. At handoff, `cover(token, window, reference, views)` installs a temporary opaque image copy before hiding those native views in the same UI turn. Reveal the Web image, confirm Web drawing readiness, then retire the entry copy. The overlay starts immediately; image encoding is never on this path.
286
+
287
+ For return, install the prepared return copy with `restore`, restore the native views underneath it, hide the Web image, and retire the return copy after Web drawing readiness. This avoids compositing live translucent native pixels over their own snapshot. Validate the token and current surface generation at every asynchronous callback. The temporary copy is noninteractive and bounded; teardown releases both copies and cached resources.
288
+
289
+ This preserves the captured background, not a continuously changing scene behind the image. Background changes must invalidate the cache through the same visual-change notifications as content updates. Video and unsupported capture surfaces retain the consumer's fallback policy.
290
+
291
+ Consumer integration tests belong to their respective repositories. Engine CI exercises its browser, Swift and Android contracts without requiring a sibling renderer checkout.
292
+
293
+ ### Final placement when returning from an opaque image (Android)
294
+
295
+ A plugin that normally measures and posts native placement on every scroll can opt
296
+ into a final placement handoff. Pass `updatePlacement: (JSONObject) -> Unit` to its
297
+ Android `NativeUIProjection` registration and bind its Web host with
298
+ `bindProjectionPlacement(element, measure, invalidate)`.
299
+
300
+ - `binding.projected` becomes true only after native confirms the image handoff.
301
+ While true, the plugin can reuse that host's last placement in full placement
302
+ batches and skip measuring it. Other hosts continue their ordinary updates.
303
+ - `measure()` returns a JSON object synchronously. The engine includes it in the
304
+ existing restore request (or an active-image recovery request). Native validates
305
+ the active image token and View identity, calls `updatePlacement`, then reveals
306
+ native and acknowledges its draw. No extra bridge round trip is introduced.
307
+ - The plugin must validate its own identity, generation and per-host sequence in
308
+ `updatePlacement`. Throw to reject a stale or invalid placement. A final update
309
+ for one host must not cause updates for another host to be dropped.
310
+ - `invalidate()` is called after a failed handoff request. Clear optimistic
311
+ placement/deduplication caches and schedule ordinary placement reconciliation.
312
+ - Dispose the binding when detaching a host. Session ownership prevents an old
313
+ registration's delayed acknowledgement from clearing a new registration's
314
+ projection state. Pause, author visibility and layout changes must still use
315
+ the plugin's normal visibility/invalidation paths.
316
+
317
+ This opt-in does not change registrations without `updatePlacement`. Android rejects
318
+ `updatePlacement` unless `captureMode = CaptureMode.VIEW`; live video and other ineligible captures
319
+ continue native placement tracking under `failedFallback = NATIVE`.
320
+
321
+ ## Capture timing
322
+
323
+ `captureDelayMs` is an optional registration setting shared by Capacitor and Cordova.
324
+ It defaults to **50 milliseconds** on iOS, Android and the lower-level `createWebProjection`
325
+ options. Values must be finite and nonnegative. It uses the existing debounce: a new
326
+ update or interaction restarts preparation after activity settles. It does not add a
327
+ second timer or delay the overlay's presentation.
328
+
329
+ Until replacement pixels are ready, the last completed image remains available. If
330
+ there is no completed image, normal Web fallback applies. Opening an overlay during
331
+ the debounce does not wait for a new capture. Consequently, increasing the delay also
332
+ increases the period in which the previous appearance may be used after an update.
333
+
334
+ A starting recommendation for **UITabBar Liquid Glass selection animations is 600 ms**.
335
+ In iOS 27.0 iPhone and iOS 27.1 Duo simulator comparisons, 50 ms captured an enlarged
336
+ selection, 300 ms still retained a small residual change, and 600 ms matched the settled
337
+ appearance observed at 1000 ms. This is a recommendation for that interaction, not an
338
+ Apple timing contract or a universal Liquid Glass duration. Measure your control's
339
+ selection, reselection and successive updates before choosing a value. Continuously
340
+ changing content has no guaranteed settled snapshot.
@@ -14,13 +14,7 @@ public final class NativeUICoveredSnapshots {
14
14
  let webFrame: CGRect
15
15
  let parts: [Part]
16
16
  }
17
- private let snapshotView: (UIWindow) -> UIView
18
- public convenience init() {
19
- self.init(snapshotView: { $0.screen.snapshotView(afterScreenUpdates: false) })
20
- }
21
- init(snapshotView: @escaping (UIWindow) -> UIView) {
22
- self.snapshotView = snapshotView
23
- }
17
+ public init() {}
24
18
  private var captures: [String: Capture] = [:]
25
19
  private var restoration: (token: String, views: [UIImageView], entry: Bool)?
26
20
  private var hiddenControls: (token: String, restore: [() -> Void])?
@@ -89,15 +83,13 @@ public final class NativeUICoveredSnapshots {
89
83
  let format = UIGraphicsImageRendererFormat()
90
84
  format.scale = scale
91
85
  format.opaque = true
92
- // Render the compositor's frozen screen, not UIKit's reconstruction of
93
- // the hierarchy: the latter loses Liquid Glass background refraction.
94
- // The replica remains detached and never changes visible UI or input.
95
- let replica = snapshotView(window)
96
- let screenBounds = window.convert(bounds, to: window.screen.coordinateSpace)
86
+ // Rasterize the visible window directly. A screen snapshot is a display
87
+ // replica, not a bitmap source; drawing that replica can succeed with
88
+ // black pixels on physical devices.
97
89
  var drawn = false
98
90
  let image = UIGraphicsImageRenderer(size: bounds.size, format: format).image { context in
99
- context.cgContext.translateBy(x: -screenBounds.minX, y: -screenBounds.minY)
100
- drawn = replica.drawHierarchy(in: window.screen.bounds, afterScreenUpdates: true)
91
+ context.cgContext.translateBy(x: -bounds.minX, y: -bounds.minY)
92
+ drawn = window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
101
93
  }
102
94
  guard drawn, let pixels = image.cgImage else { complete([:]); return }
103
95
  let token = UUID().uuidString
@@ -59,15 +59,17 @@ public final class NativeUIProjection: NSObject, WKScriptMessageHandler {
59
59
  receiveListeners: ReceiveListeners = .init(),
60
60
  allowedOrigins: Set<String> = ["capacitor://localhost", "app://localhost", "https://localhost", "http://localhost"],
61
61
  failedFallback: FailedFallback = .web, runtime: Runtime = .capacitor,
62
+ captureDelayMs: Double = 50,
62
63
  canCapture: @escaping () -> Bool = { true }, view: @escaping () -> UIView?) throws {
63
64
  try self.init(webView: webView, plugin: plugin, projectionSelector: projectionSelector,
64
65
  receiveListeners: receiveListeners, allowedOrigins: allowedOrigins, failedFallback: failedFallback, runtime: runtime,
65
- canCapture: canCapture, snapshots: NativeUICoveredSnapshots(), view: view)
66
+ captureDelayMs: captureDelayMs, canCapture: canCapture, snapshots: NativeUICoveredSnapshots(), view: view)
66
67
  }
67
68
  init(webView: WKWebView?, plugin: String, projectionSelector: String,
68
69
  receiveListeners: ReceiveListeners = .init(),
69
70
  allowedOrigins: Set<String> = ["capacitor://localhost", "app://localhost", "https://localhost", "http://localhost"],
70
71
  failedFallback: FailedFallback = .web, runtime: Runtime = .capacitor,
72
+ captureDelayMs: Double = 50,
71
73
  canCapture: @escaping () -> Bool = { true }, snapshots: NativeUICoveredSnapshots,
72
74
  view: @escaping () -> UIView?) throws {
73
75
  self.allowedOriginsJSON = String(data: try JSONSerialization.data(withJSONObject: Array(allowedOrigins)), encoding: .utf8)!
@@ -78,9 +80,10 @@ public final class NativeUIProjection: NSObject, WKScriptMessageHandler {
78
80
  self.webView = webView; self.views = { view().map { ["view": $0] } ?? [:] }; self.origins = allowedOrigins; self.canCapture = canCapture
79
81
  super.init()
80
82
  precondition(Thread.isMainThread)
81
- guard !projectionSelector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw ProjectionError.invalidConfiguration }
83
+ guard captureDelayMs.isFinite, captureDelayMs >= 0, captureDelayMs <= 2147483647,
84
+ !projectionSelector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw ProjectionError.invalidConfiguration }
82
85
  let config: [String: Any] = ["id": id, "plugin": plugin, "projectionSelector": projectionSelector,
83
- "receiveListeners": receiveListeners.configuration, "failedFallback": failedFallback.rawValue, "runtime": runtime.rawValue]
86
+ "captureDelayMs": captureDelayMs, "receiveListeners": receiveListeners.configuration, "failedFallback": failedFallback.rawValue, "runtime": runtime.rawValue]
84
87
  let json = String(data: try JSONSerialization.data(withJSONObject: config), encoding: .utf8)!
85
88
  let allowed = allowedOriginsJSON
86
89
  let runtime = try Self.runtime()