diff --git a/CLAUDE.md b/CLAUDE.md index ff196d4..bdd8902 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,16 +117,37 @@ code about to be rewritten in React anyway. Explore → go to Explore; otherwise exit. Registering a listener suppresses Capacitor's default history navigation. -**Edge-to-edge is handled natively, not avoided.** Android 16 is API 36 and -Capacitor 8 targets SDK 36, which is exactly the combination where -`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. +**System bars: let Capacitor do it, by dropping `viewport-fit=cover`.** The app +renders between the status and gesture bars, always. -The push retries on a bounded schedule (25 × 200ms) because insets typically -arrive before the page has run any JS. +Capacitor 8 ships a `SystemBars` plugin that owns inset handling from a listener +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`, `/android`, `/haptics`, `/preferences`, `/app`. @@ -176,9 +197,14 @@ function rewrites `#strip` wholesale. `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"` +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) **adb needs a udev rule.** The phone is USB `1bbb:0c01`. Ubuntu's diff --git a/android/app/src/main/java/dev/ncode/sp404layers/MainActivity.java b/android/app/src/main/java/dev/ncode/sp404layers/MainActivity.java index adc49f8..09c0435 100644 --- a/android/app/src/main/java/dev/ncode/sp404layers/MainActivity.java +++ b/android/app/src/main/java/dev/ncode/sp404layers/MainActivity.java @@ -1,81 +1,17 @@ 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; /** - * Android 15+ forces edge-to-edge for apps targeting SDK 35, and Android 16 - * 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. + * Deliberately empty. * - * This bridges the gap: the app draws behind the bars, and the real insets are - * pushed into CSS custom properties that native.css consumes. + * An earlier version installed its own OnApplyWindowInsetsListener on the + * 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 { - - @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); - } - })); - } -} +public class MainActivity extends BridgeActivity {} diff --git a/capacitor.config.json b/capacitor.config.json index 076b60b..8cece88 100644 --- a/capacitor.config.json +++ b/capacitor.config.json @@ -8,5 +8,11 @@ }, "server": { "androidScheme": "https" + }, + "plugins": { + "SystemBars": { + "style": "DARK", + "insetsHandling": "css" + } } } diff --git a/native/native.css b/native/native.css index 66a0b4e..d204008 100644 --- a/native/native.css +++ b/native/native.css @@ -2,45 +2,23 @@ reference/ is never edited; sync-www.sh injects this as the last stylesheet in , so on equal specificity these rules win. */ -:root{ - /* 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); +/* No inset handling here on purpose. - /* The reference derives --sab from env(safe-area-inset-bottom), which is - always 0px inside an Android WebView. Re-point it at the real inset. - .bnav and .sheet both key off --sab, so they need no further changes. */ - --sab: var(--ins-b); -} + sync-www.sh strips viewport-fit=cover from the generated www/, so Capacitor's + SystemBars plugin pads the decor view and the WebView is laid out between the + status and gesture bars already. env(safe-area-inset-*) is therefore 0, the + 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){ - /* 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) ---------- 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 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 - single .ctxbtn below, leaving one chip row with real touch targets. - Phase 2 must carry this - see CLAUDE.md. */ + (measured: 0px gap). native.js collapses both rows into the two pickers + below. Phase 2 must carry this - see CLAUDE.md. */ /* 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 @@ -73,8 +51,8 @@ } } -/* Context picker rows inside the sheet. Uses CTXHINT from the reference, which - the phone layout otherwise never shows. */ +/* Mode and layer rows inside the sheet. The mode rows use CTXHINT from the + reference, which the phone layout otherwise never shows. */ .ctxopt{ display: block; width: 100%; text-align: left; color: inherit; background: var(--card); border: 1px solid var(--line); border-radius: 10px; diff --git a/native/native.js b/native/native.js index 33f7c45..f70dcf5 100644 --- a/native/native.js +++ b/native/native.js @@ -16,15 +16,10 @@ var cap = window.Capacitor; var P = (cap && cap.Plugins) || {}; - /* Called from MainActivity's window-inset listener. Defined unconditionally - so the native side never has to feature-detect it. Values arrive in dp. */ - window.__applyInsets = function (top, right, bottom, left) { - var s = document.documentElement.style; - s.setProperty("--ins-t", top + "px"); - s.setProperty("--ins-r", right + "px"); - s.setProperty("--ins-b", bottom + "px"); - s.setProperty("--ins-l", left + "px"); - }; + /* Insets are deliberately not handled here. sync-www.sh strips + viewport-fit=cover, so Capacitor's SystemBars plugin pads the decor view + and the WebView is already laid out between the system bars. An earlier + __applyInsets bridge raced with that plugin - see CLAUDE.md. */ if (!(cap && cap.isNativePlatform && cap.isNativePlatform())) return; diff --git a/scripts/sync-www.sh b/scripts/sync-www.sh index f55dcef..b59b582 100755 --- a/scripts/sync-www.sh +++ b/scripts/sync-www.sh @@ -51,8 +51,22 @@ html = patch(html, "", html = patch(html, "", '\n', "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) -print("sync-www: patched index.html (4/4 anchors matched)") +print("sync-www: patched index.html (5/5 anchors matched)") PY echo "sync-www: $OUT/ built from $SRC/"