Render between the system bars deterministically; delete my inset bridge

The app sometimes drew under the status and gesture bars and sometimes
between them. Cause: Capacitor 8 ships a SystemBars plugin that already
handles insets, from a listener on the decor view, and my MainActivity
had installed another one on the WebView. Different views, so they
stacked rather than replaced each other and the result depended on which
fired last.

SystemBars switches on the viewport meta tag. With viewport-fit=cover it
passes insets through once onPageCommitVisible has checked the tag, decor
padding drops to 0 and the WebView grows 735 -> 820, leaving the page to
place itself from injected CSS vars. Without it the plugin pads the decor
view and the WebView is laid out between the bars already.

So sync-www.sh now strips viewport-fit=cover from the generated www/
(patch 5), MainActivity is an empty BridgeActivity, native.css and
native.js no longer touch insets, and bar style moves to
capacitor.config.json. No native->web handshake remains, so there is
nothing to race and no launch-time layout jump.

Verified identical across 3 cold launches, 2 resumes and an in-place
reload: h=735 padTop=8px overflowBy=0, panel 485px, pad rows 44.8px.

Costs no space: 820-45-114 and 735-8-66 both leave 661px. The bars take
the same room either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 23:26:31 +03:00
parent 5f9d89d9fb
commit 066b9e8c34
6 changed files with 83 additions and 128 deletions

View File

@@ -117,16 +117,37 @@ code about to be rewritten in React anyway.
Explore → go to Explore; otherwise exit. Registering a listener suppresses Explore → go to Explore; otherwise exit. Registering a listener suppresses
Capacitor's default history navigation. Capacitor's default history navigation.
**Edge-to-edge is handled natively, not avoided.** Android 16 is API 36 and **System bars: let Capacitor do it, by dropping `viewport-fit=cover`.** The app
Capacitor 8 targets SDK 36, which is exactly the combination where renders between the status and gesture bars, always.
`windowOptOutEdgeToEdgeEnforcement` stops working. An Android WebView never
populates `env(safe-area-inset-*)`, so `MainActivity` installs an
`OnApplyWindowInsetsListener`, converts px → dp, and pushes the values into
`--ins-t/r/b/l` via `window.__applyInsets()`. `native.css` re-points the
reference's `--sab` at `--ins-b`, so `.bnav` and `.sheet` need no changes.
The push retries on a bounded schedule (25 × 200ms) because insets typically Capacitor 8 ships a `SystemBars` plugin that owns inset handling from a listener
arrive before the page has run any JS. on the **decor view**. Its behaviour hinges on the viewport meta tag:
- **with `viewport-fit=cover`** (what `reference/` has), `shouldPassthroughInsets`
becomes true once `onPageCommitVisible` has checked the tag and called
`requestApplyInsets()`. Decor padding drops to 0, the WebView grows from 735 to
820, and the page must place itself from injected CSS variables.
- **without it**, the plugin pads the decor view instead. The WebView is 735px
and already sits between the bars; `env(safe-area-inset-*)` and the
reference's `--sab` are 0, and `.bnav` and `.sheet` are correct as written.
So `sync-www.sh` strips `viewport-fit=cover` from the generated `www/` (patch 5).
No native→web handshake exists, so there is nothing to race and no launch-time
layout jump. Verified identical across cold launch, resume and in-place reload:
`h=735 padTop=8px overflowBy=0`.
**This costs no space.** 820 − 45 − 114 and 735 − 8 − 66 both leave 661px, because
the bars take the same room whether the WebView is padded or draws underneath.
Reclaiming that 85px would mean *hiding* the bars, which is a different choice.
An earlier version did the opposite: `MainActivity` installed its own
`OnApplyWindowInsetsListener` on the WebView and pushed `--ins-t/r/b/l` through
`window.__applyInsets()`, with a 25 × 200ms retry. That was written without
realising Capacitor already had a plugin for this. The two listeners were on
different views, so they stacked rather than replaced each other, and the app
drew under the bars at random depending on which fired last. `MainActivity` is
now empty and bar styling is configured in `capacitor.config.json`
(`SystemBars.style: DARK`).
**No community plugins.** Only first-party: `@capacitor/core`, `/cli`, **No community plugins.** Only first-party: `@capacitor/core`, `/cli`,
`/android`, `/haptics`, `/preferences`, `/app`. `/android`, `/haptics`, `/preferences`, `/app`.
@@ -176,9 +197,14 @@ function rewrites `#strip` wholesale.
`cap sync` does not regenerate these — keep them when recreating `android/`: `cap sync` does not regenerate these — keep them when recreating `android/`:
- `android/app/src/main/java/dev/ncode/sp404layers/MainActivity.java` — inset bridge
- `android/app/src/main/AndroidManifest.xml` — `android:screenOrientation="portrait"` - `android/app/src/main/AndroidManifest.xml` — `android:screenOrientation="portrait"`
That is the only one. `MainActivity.java` is deliberately an empty
`BridgeActivity`: everything it used to do is either configured in
`capacitor.config.json` or handled by Capacitor's own `SystemBars` plugin. If
you find yourself adding an inset listener there, read the system-bars decision
above first — that path has already been tried and reverted.
## Device notes (TCL 50 PRO NXTPAPER 5G, Android 16 / API 36) ## Device notes (TCL 50 PRO NXTPAPER 5G, Android 16 / API 36)
**adb needs a udev rule.** The phone is USB `1bbb:0c01`. Ubuntu's **adb needs a udev rule.** The phone is USB `1bbb:0c01`. Ubuntu's

View File

@@ -1,81 +1,17 @@
package dev.ncode.sp404layers; package dev.ncode.sp404layers;
import android.os.Bundle;
import android.view.View;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
/** /**
* Android 15+ forces edge-to-edge for apps targeting SDK 35, and Android 16 * Deliberately empty.
* removes the windowOptOutEdgeToEdgeEnforcement escape hatch entirely at
* targetSdk 36 (which is Capacitor 8's default). An Android WebView does not
* populate env(safe-area-inset-*), so the web layer has no way to discover the
* system bar geometry on its own.
* *
* This bridges the gap: the app draws behind the bars, and the real insets are * An earlier version installed its own OnApplyWindowInsetsListener on the
* pushed into CSS custom properties that native.css consumes. * WebView and pushed the insets into CSS custom properties. That was written
* before I found that Capacitor 8 ships a SystemBars plugin which already does
* this, from a listener on the decor view.
*
* The two stacked rather than replaced each other, and the app ended up drawing
* under the system bars at random. See the "Device notes" section of CLAUDE.md.
* Bar styling and inset handling are now configured in capacitor.config.json.
*/ */
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Draw behind the system bars.
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
// The UI is dark, so the status/nav bar icons must be light.
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
if (controller != null) {
controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(false);
}
final View webView = getBridge().getWebView();
ViewCompat.setOnApplyWindowInsetsListener(webView, (v, windowInsets) -> {
Insets bars = windowInsets.getInsets(
WindowInsetsCompat.Type.systemBars()
| WindowInsetsCompat.Type.displayCutout());
// CSS pixels are density-independent; WindowInsets are physical px.
float density = getResources().getDisplayMetrics().density;
final int top = Math.round(bars.top / density);
final int right = Math.round(bars.right / density);
final int bottom = Math.round(bars.bottom / density);
final int left = Math.round(bars.left / density);
// Returns "1" once applied, "0" while native.js has not loaded yet.
final String js = String.format(
java.util.Locale.US,
"(window.__applyInsets?(window.__applyInsets(%d,%d,%d,%d),1):0)",
top, right, bottom, left);
// Insets usually arrive before the page has run any JS, so retry on
// a bounded schedule instead of firing once into the void.
applyWithRetry(js, 0);
// Do not consume: other listeners (and the IME) may still need these.
return windowInsets;
});
}
private static final int INSET_RETRY_LIMIT = 25; // ~5s at 200ms
private static final long INSET_RETRY_DELAY_MS = 200L;
/** Pushes the inset JS, retrying until native.js has defined __applyInsets. */
private void applyWithRetry(final String js, final int attempt) {
final View webView = getBridge().getWebView();
webView.post(() -> getBridge().getWebView().evaluateJavascript(js, value -> {
boolean applied = value != null && value.contains("1");
if (!applied && attempt < INSET_RETRY_LIMIT) {
webView.postDelayed(() -> applyWithRetry(js, attempt + 1), INSET_RETRY_DELAY_MS);
}
}));
}
}

View File

@@ -8,5 +8,11 @@
}, },
"server": { "server": {
"androidScheme": "https" "androidScheme": "https"
},
"plugins": {
"SystemBars": {
"style": "DARK",
"insetsHandling": "css"
}
} }
} }

View File

@@ -2,45 +2,23 @@
reference/ is never edited; sync-www.sh injects this as the last stylesheet reference/ is never edited; sync-www.sh injects this as the last stylesheet
in <head>, so on equal specificity these rules win. */ in <head>, so on equal specificity these rules win. */
:root{ /* No inset handling here on purpose.
/* Filled in from Android WindowInsets by MainActivity via __applyInsets().
The env() defaults keep the page correct in a plain desktop browser, where
env() works and the native bridge is absent. */
--ins-t: env(safe-area-inset-top, 0px);
--ins-b: env(safe-area-inset-bottom, 0px);
--ins-l: env(safe-area-inset-left, 0px);
--ins-r: env(safe-area-inset-right, 0px);
/* The reference derives --sab from env(safe-area-inset-bottom), which is sync-www.sh strips viewport-fit=cover from the generated www/, so Capacitor's
always 0px inside an Android WebView. Re-point it at the real inset. SystemBars plugin pads the decor view and the WebView is laid out between the
.bnav and .sheet both key off --sab, so they need no further changes. */ status and gesture bars already. env(safe-area-inset-*) is therefore 0, the
--sab: var(--ins-b); reference's --sab resolves to 0, and .bnav and .sheet are correct as written.
}
An earlier version mapped --sab onto values pushed from MainActivity. That
raced with Capacitor's own listener - see CLAUDE.md. */
@media (max-width:820px){ @media (max-width:820px){
/* The reference sets `padding: 8px 8px calc(var(--nav) + var(--sab) + 8px)`.
Bottom already accounts for --sab; add the top and side insets so the unit
panel clears the status bar and any display cutout. */
.app{
padding-top: calc(8px + var(--ins-t));
padding-left: calc(8px + var(--ins-l));
padding-right: calc(8px + var(--ins-r));
}
/* Bottom nav is position:fixed and spans the full width, so it needs the
side insets itself. Its height/padding-bottom already follow --sab. */
.bnav{
padding-left: var(--ins-l);
padding-right: var(--ins-r);
}
/* ---------- strip ergonomics (Phase 1 UI fix) ---------- /* ---------- strip ergonomics (Phase 1 UI fix) ----------
The reference spaces its two chip rows with `#strip .chips + .chips`, but The reference spaces its two chip rows with `#strip .chips + .chips`, but
on phones the .lbl heading between them is display:none and still in the on phones the .lbl heading between them is display:none and still in the
DOM, so the adjacent-sibling selector never matches and the rows sit flush DOM, so the adjacent-sibling selector never matches and the rows sit flush
(measured: 0px gap). native.js collapses the 7 context chips into the (measured: 0px gap). native.js collapses both rows into the two pickers
single .ctxbtn below, leaving one chip row with real touch targets. below. Phase 2 must carry this - see CLAUDE.md. */
Phase 2 must carry this - see CLAUDE.md. */
/* Mode and layer pickers share one row: only one layer is ever active, so /* Mode and layer pickers share one row: only one layer is ever active, so
neither needs a scrolling row of its own. One 44px row instead of two gives neither needs a scrolling row of its own. One 44px row instead of two gives
@@ -73,8 +51,8 @@
} }
} }
/* Context picker rows inside the sheet. Uses CTXHINT from the reference, which /* Mode and layer rows inside the sheet. The mode rows use CTXHINT from the
the phone layout otherwise never shows. */ reference, which the phone layout otherwise never shows. */
.ctxopt{ .ctxopt{
display: block; width: 100%; text-align: left; color: inherit; display: block; width: 100%; text-align: left; color: inherit;
background: var(--card); border: 1px solid var(--line); border-radius: 10px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;

View File

@@ -16,15 +16,10 @@
var cap = window.Capacitor; var cap = window.Capacitor;
var P = (cap && cap.Plugins) || {}; var P = (cap && cap.Plugins) || {};
/* Called from MainActivity's window-inset listener. Defined unconditionally /* Insets are deliberately not handled here. sync-www.sh strips
so the native side never has to feature-detect it. Values arrive in dp. */ viewport-fit=cover, so Capacitor's SystemBars plugin pads the decor view
window.__applyInsets = function (top, right, bottom, left) { and the WebView is already laid out between the system bars. An earlier
var s = document.documentElement.style; __applyInsets bridge raced with that plugin - see CLAUDE.md. */
s.setProperty("--ins-t", top + "px");
s.setProperty("--ins-r", right + "px");
s.setProperty("--ins-b", bottom + "px");
s.setProperty("--ins-l", left + "px");
};
if (!(cap && cap.isNativePlatform && cap.isNativePlatform())) return; if (!(cap && cap.isNativePlatform && cap.isNativePlatform())) return;

View File

@@ -51,8 +51,22 @@ html = patch(html, "</head>",
html = patch(html, "</body>", html = patch(html, "</body>",
'<script src="native.js"></script>\n</body>', "body close") '<script src="native.js"></script>\n</body>', "body close")
# 5. drop viewport-fit=cover.
#
# Capacitor's SystemBars plugin switches behaviour on this exact string. With
# it, shouldPassthroughInsets becomes true once onPageCommitVisible has checked
# the meta tag, decor padding drops to 0, the WebView grows from 735 to 820 and
# the page must position itself from injected CSS variables - a native->web
# handshake that raced, so the app sometimes drew under the status and gesture
# bars. Without it the plugin pads the decor view instead and the page is always
# laid out between the bars, with no handshake and no launch-time jump.
#
# The web build keeps the cover behaviour; this only applies to the APK.
html = patch(html, 'content="width=device-width,initial-scale=1,viewport-fit=cover"',
'content="width=device-width,initial-scale=1"', "viewport-fit=cover")
open(path, "w", encoding="utf-8").write(html) open(path, "w", encoding="utf-8").write(html)
print("sync-www: patched index.html (4/4 anchors matched)") print("sync-www: patched index.html (5/5 anchors matched)")
PY PY
echo "sync-www: $OUT/ built from $SRC/" echo "sync-www: $OUT/ built from $SRC/"