fix(android): clear the system bars and display cutout (UR-066)
The bottom nav rendered under the Android navigation bar, and full-screen
playback controls spilled into unusable screen edges. It looked device-specific
(Motorola bad, Fairphone fine) but every device was equally unpadded — only the
intrusion differed: a tall opaque 3-button bar swallows the nav, a thin
translucent gesture pill overlaps harmlessly.
None of the app's safe-area handling was ever active, for two independent
reasons:
1. app.html had no `viewport-fit=cover`, so every `env(safe-area-inset-*)`
resolved to 0px — the padding in app.css and BottomUi was a no-op.
2. Android WebView maps only the *display cutout* into `env()`; the status bar
and navigation bar are never reported. With enableEdgeToEdge() and
targetSdk 36 (enforced from 35, opt-out ignored from 36) the WebView always
spans them, so CSS could not learn about them by any route.
WindowInsetsBridge now reads `systemBars() | displayCutout()` and publishes
`--jt-inset-*` CSS custom properties, both pushed on every inset change
(rotation, nav-mode switch, PiP) and pullable via `AndroidInsets.get()` — the
pull is required because the first inset pass lands before the document exists
and a page load wipes the pushed inline style. app.css folds them with `env()`
via `max()` into `--safe-*`, the only thing components may pad from.
Exactly one element owns each edge: the shell takes top/left/right, BottomUi
takes bottom (inside its surface box, so the colour extends behind the gesture
bar), and shellReservesBottomInset hands bottom back to the shell on routes with
no bottom UI. The full-screen players inset their control layers only, leaving
video and artwork edge-to-edge.
The theme's `fitsSystemWindows=true` claimed the opposite of what actually
happened — overridden at runtime, ignored at this target SDK — and is removed.
Also converts six nested `h-screen`/`min-h-screen` boxes to `h-full`: the shell
is `h-screen` *and* inset-padded, so its content box is `100vh - safe-top` and
any nested 100vh box overflows by exactly the inset (the library column would
have clipped its own BottomUi). A test guards against reintroduction.
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
showGlobalHeader,
|
||||
routeOwnsLayout,
|
||||
showBottomUi,
|
||||
shellReservesBottomInset,
|
||||
} from "./layoutShell";
|
||||
|
||||
const authed = (pathname: string) => ({ pathname, isAuthenticated: true });
|
||||
@@ -149,3 +150,32 @@ describe("structural invariant: every route that shows bottom UI has a scroller
|
||||
expect(routeOwnsLayout({ pathname: "/player/x" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Who owns the bottom safe-area inset.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112 | UT-098
|
||||
*
|
||||
* Exactly one element must reserve `--safe-bottom`, or the Android gesture bar
|
||||
* is either ignored (nav swallowed) or double-padded (a dead strip above it).
|
||||
* BottomUi owns it whenever it renders — the padding sits inside its surface
|
||||
* box so the colour extends behind the bar. Routes with no BottomUi (login, the
|
||||
* full-screen player) leave the app shell to reserve it instead.
|
||||
*/
|
||||
describe("shellReservesBottomInset", () => {
|
||||
it("defers to BottomUi on every route that renders one", () => {
|
||||
for (const pathname of ["/", "/search", "/downloads", "/settings", "/library"]) {
|
||||
expect(showBottomUi(authed(pathname))).toBe(true);
|
||||
expect(shellReservesBottomInset(authed(pathname))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("reserves the inset itself on routes with no bottom UI", () => {
|
||||
expect(shellReservesBottomInset(authed("/login"))).toBe(true);
|
||||
expect(shellReservesBottomInset(authed("/player/x"))).toBe(true);
|
||||
});
|
||||
|
||||
it("defers on the unauthenticated shell, where the mini player still renders", () => {
|
||||
expect(shellReservesBottomInset({ pathname: "/", isAuthenticated: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,3 +98,19 @@ export function showGlobalHeader({
|
||||
export function showBottomUi(input: BottomUiVisibilityInput): boolean {
|
||||
return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app shell itself must reserve the bottom safe-area inset
|
||||
* (`--safe-bottom`, i.e. the Android navigation/gesture bar).
|
||||
*
|
||||
* Exactly one element may reserve it. BottomUi owns it whenever it renders,
|
||||
* because the padding belongs *inside* its surface box so the colour extends
|
||||
* behind the bar rather than leaving a strip of page background. On routes with
|
||||
* no bottom UI at all (login, the full-screen player) nothing else would, so
|
||||
* the shell takes it.
|
||||
*
|
||||
* TRACES: UR-066 | DR-112
|
||||
*/
|
||||
export function shellReservesBottomInset(input: BottomUiVisibilityInput): boolean {
|
||||
return !showBottomUi(input);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
ZERO_INSETS,
|
||||
parseNativeInsets,
|
||||
safeAreaCssVars,
|
||||
applySafeAreaInsets,
|
||||
readNativeInsets,
|
||||
initSafeArea,
|
||||
INSETS_CHANGED_EVENT,
|
||||
} from "./safeArea";
|
||||
|
||||
/**
|
||||
* Safe-area (window inset) plumbing for Android.
|
||||
*
|
||||
* TRACES: UR-066 | IR-031, DR-112 | UT-094, UT-095, UT-096, UT-097
|
||||
*
|
||||
* Regression guard for "the bottom nav is off the bottom of the screen on some
|
||||
* devices (Motorola) but not others (Fairphone)".
|
||||
*
|
||||
* Two independent defects produced it:
|
||||
*
|
||||
* 1. `src/app.html` shipped `<meta name="viewport" content="width=device-width,
|
||||
* initial-scale=1">` — no `viewport-fit=cover`. Per the CSS Env spec, every
|
||||
* `env(safe-area-inset-*)` resolves to **0px** unless the viewport opts into
|
||||
* `cover`. So the `env()` padding in app.css and BottomUi.svelte was a
|
||||
* no-op on every device.
|
||||
* 2. Even with `viewport-fit=cover`, Android WebView only maps the **display
|
||||
* cutout** into `env(safe-area-inset-*)` — never the status bar or the
|
||||
* navigation/gesture bar. MainActivity calls `enableEdgeToEdge()` and the
|
||||
* app targets SDK 36 (edge-to-edge is mandatory from SDK 35 and the opt-out
|
||||
* is ignored from SDK 36), so the WebView always spans the full window
|
||||
* including the system bars. CSS alone can never learn about them.
|
||||
*
|
||||
* The device split was only in how much the bars intrude: a thin translucent
|
||||
* gesture pill overlaps harmlessly, a tall opaque 3-button bar swallows the nav
|
||||
* outright. Both devices were equally unpadded.
|
||||
*
|
||||
* The fix pushes real `WindowInsets` from Kotlin into CSS custom properties.
|
||||
* These tests pin the frontend half of that contract.
|
||||
*/
|
||||
describe("parseNativeInsets", () => {
|
||||
it("parses the JSON payload the native bridge returns", () => {
|
||||
expect(parseNativeInsets('{"top":24,"right":0,"bottom":48,"left":0}')).toEqual({
|
||||
top: 24,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
left: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts an already-parsed object", () => {
|
||||
expect(parseNativeInsets({ top: 1, right: 2, bottom: 3, left: 4 })).toEqual({
|
||||
top: 1,
|
||||
right: 2,
|
||||
bottom: 3,
|
||||
left: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats missing, non-finite and negative edges as zero rather than emitting NaN", () => {
|
||||
// A NaN would serialise to "NaNpx" and silently kill the whole padding
|
||||
// declaration, which is exactly the failure mode being guarded against.
|
||||
expect(parseNativeInsets('{"top":-5,"bottom":"48"}')).toEqual({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 48,
|
||||
left: 0,
|
||||
});
|
||||
expect(parseNativeInsets({ top: Number.NaN, right: Infinity })).toEqual(ZERO_INSETS);
|
||||
});
|
||||
|
||||
it("returns null for input that is not an inset payload at all", () => {
|
||||
expect(parseNativeInsets("not json")).toBeNull();
|
||||
expect(parseNativeInsets(null)).toBeNull();
|
||||
expect(parseNativeInsets(undefined)).toBeNull();
|
||||
expect(parseNativeInsets(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeAreaCssVars / applySafeAreaInsets", () => {
|
||||
it("emits px-suffixed custom properties for all four edges", () => {
|
||||
expect(safeAreaCssVars({ top: 24, right: 0, bottom: 48, left: 12 })).toEqual({
|
||||
"--jt-inset-top": "24px",
|
||||
"--jt-inset-right": "0px",
|
||||
"--jt-inset-bottom": "48px",
|
||||
"--jt-inset-left": "12px",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the custom properties onto the target element", () => {
|
||||
const el = document.createElement("div");
|
||||
applySafeAreaInsets(el, { top: 24, right: 1, bottom: 48, left: 2 });
|
||||
|
||||
expect(el.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-right")).toBe("1px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
|
||||
expect(el.style.getPropertyValue("--jt-inset-left")).toBe("2px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readNativeInsets", () => {
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidInsets;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when the bridge is absent (desktop, iOS, dev server)", () => {
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
});
|
||||
|
||||
it("reads and parses the bridge payload", () => {
|
||||
window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
|
||||
expect(readNativeInsets()).toEqual({ top: 24, right: 0, bottom: 48, left: 0 });
|
||||
});
|
||||
|
||||
it("returns null when the bridge object is a stale WebView proxy", () => {
|
||||
// Same failure mode as the background-audio bridge: the injected object
|
||||
// stays truthy across a page load while its methods vanish. Must not throw
|
||||
// out of layout init.
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
window.AndroidInsets = {} as unknown as { get(): string };
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
|
||||
window.AndroidInsets = {
|
||||
get: () => {
|
||||
throw new TypeError("get is not a function");
|
||||
},
|
||||
};
|
||||
expect(readNativeInsets()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("initSafeArea", () => {
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
delete (window as unknown as Record<string, unknown>).AndroidInsets;
|
||||
document.documentElement.removeAttribute("style");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = null;
|
||||
});
|
||||
|
||||
it("primes the document element from the bridge on start", () => {
|
||||
window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
|
||||
|
||||
stop = initSafeArea();
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
|
||||
});
|
||||
|
||||
it("re-applies insets when native reports a change (rotation, nav-mode switch)", () => {
|
||||
let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
|
||||
window.AndroidInsets = { get: () => payload };
|
||||
|
||||
stop = initSafeArea();
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
|
||||
// Rotated to landscape: the cutout moves to the left edge, the gesture bar
|
||||
// shrinks. Native re-pushes and fires the change event.
|
||||
payload = '{"top":0,"right":0,"bottom":24,"left":44}';
|
||||
window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("0px");
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-left")).toBe("44px");
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("24px");
|
||||
});
|
||||
|
||||
it("leaves the custom properties unset with no bridge, so env() keeps the field", () => {
|
||||
// On iOS/desktop the `env(safe-area-inset-*)` half of the max() must win;
|
||||
// writing an explicit 0px here would clobber it.
|
||||
stop = initSafeArea();
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("");
|
||||
});
|
||||
|
||||
it("stops listening once torn down", () => {
|
||||
let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
|
||||
window.AndroidInsets = { get: () => payload };
|
||||
|
||||
const teardown = initSafeArea();
|
||||
teardown();
|
||||
|
||||
payload = '{"top":99,"right":99,"bottom":99,"left":99}';
|
||||
window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Static guards. The two root-cause defects were both single lines of markup /
|
||||
* CSS that no runtime test could reach, so pin them at the source level.
|
||||
*/
|
||||
describe("safe-area wiring in source", () => {
|
||||
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), "utf8");
|
||||
|
||||
it("app.html opts the viewport into viewport-fit=cover", () => {
|
||||
const viewport = read("src/app.html").match(/<meta\s+name="viewport"[^>]*>/i)?.[0];
|
||||
|
||||
expect(viewport, "no viewport meta tag found in src/app.html").toBeTruthy();
|
||||
expect(viewport).toMatch(/viewport-fit\s*=\s*cover/);
|
||||
});
|
||||
|
||||
it("app.css derives --safe-* from both env() and the native --jt-inset-* vars", () => {
|
||||
const css = read("src/app.css");
|
||||
|
||||
for (const edge of ["top", "right", "bottom", "left"]) {
|
||||
expect(css).toMatch(
|
||||
new RegExp(
|
||||
`--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("no component pads directly from env() — everything goes through --safe-*", () => {
|
||||
// A bare env() is 0 in Android WebView for the system bars, which is the
|
||||
// bug. app.css is the one legal place it may appear (inside the max()).
|
||||
const offenders = [
|
||||
"src/lib/components/BottomUi.svelte",
|
||||
"src/routes/+layout.svelte",
|
||||
"src/lib/components/player/VideoPlayer.svelte",
|
||||
"src/lib/components/player/AudioPlayer.svelte",
|
||||
].filter((f) => read(f).includes("env(safe-area-inset"));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the bottom UI reserves the bottom inset so the nav clears the gesture bar", () => {
|
||||
expect(read("src/lib/components/BottomUi.svelte")).toMatch(/pb-\[var\(--safe-bottom\)\]/);
|
||||
});
|
||||
|
||||
it("only the app shell measures itself against the viewport", () => {
|
||||
// The shell is `h-screen` AND inset-padded, so its content box is
|
||||
// `100vh - safe-top`. Any nested `h-screen`/`min-h-screen` is therefore
|
||||
// taller than the space it was given and overflows by exactly the inset —
|
||||
// the library column's `h-screen` clipped its own BottomUi that way. Nested
|
||||
// full-height boxes must use `h-full`/`min-h-full` and inherit the shell's
|
||||
// already-inset height.
|
||||
const svelteFilesIn = (dir: string): string[] =>
|
||||
readdirSync(resolve(process.cwd(), dir), { recursive: true, encoding: "utf8" })
|
||||
.filter((f) => f.endsWith(".svelte"))
|
||||
.map((f) => `${dir}/${f}`);
|
||||
|
||||
const offenders = [...svelteFilesIn("src/routes"), ...svelteFilesIn("src/lib/components")]
|
||||
.filter((f) => f !== "src/routes/+layout.svelte")
|
||||
.filter((f) => /class=[^>]*\bh-screen\b|class=[^>]*\bmin-h-screen\b/.test(read(f)));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Safe-area (window inset) plumbing.
|
||||
*
|
||||
* TRACES: UR-066 | IR-031, DR-112
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* `MainActivity` calls `enableEdgeToEdge()`, and the app targets SDK 36 — from
|
||||
* SDK 35 edge-to-edge is mandatory and from SDK 36 the opt-out is ignored — so
|
||||
* the Tauri WebView always spans the **entire window**, underneath the status
|
||||
* bar, the navigation/gesture bar and the display cutout.
|
||||
*
|
||||
* CSS cannot discover that on its own:
|
||||
*
|
||||
* - `env(safe-area-inset-*)` resolves to `0px` unless the viewport declares
|
||||
* `viewport-fit=cover` (see `src/app.html`), and
|
||||
* - even then, Android WebView only maps the **display cutout** into
|
||||
* `env(safe-area-inset-*)`. The status bar and the navigation bar are never
|
||||
* reported. Unlike iOS Safari, there is no CSS-visible system-bar inset.
|
||||
*
|
||||
* So native reads the real `WindowInsets` (`systemBars() | displayCutout()`)
|
||||
* and pushes them in as CSS custom properties; `src/app.css` folds them
|
||||
* together with `env()` via `max()` so iOS/desktop keep working unchanged:
|
||||
*
|
||||
* ```css
|
||||
* --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--jt-inset-bottom, 0px));
|
||||
* ```
|
||||
*
|
||||
* Two delivery paths, because either alone is insufficient:
|
||||
*
|
||||
* - **push** — `WindowInsetsBridge` evaluates JS into the WebView on every
|
||||
* inset change (rotation, nav-mode switch, PiP enter/exit). Needed because
|
||||
* insets change after load.
|
||||
* - **pull** — `initSafeArea()` reads `window.AndroidInsets.get()` at startup.
|
||||
* Needed because the first inset pass usually lands *before* the SvelteKit
|
||||
* document exists, and a page load wipes any inline style native had set.
|
||||
*/
|
||||
|
||||
/** Window insets in CSS pixels, one per edge. */
|
||||
export interface SafeAreaInsets {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
/** No insets — the desktop/dev default. */
|
||||
export const ZERO_INSETS: SafeAreaInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
/** DOM event native fires after pushing a new set of insets. */
|
||||
export const INSETS_CHANGED_EVENT = "jellytau-insets-changed";
|
||||
|
||||
const EDGES = ["top", "right", "bottom", "left"] as const;
|
||||
|
||||
/** The native @JavascriptInterface installed by MainActivity (Android only). */
|
||||
interface AndroidInsetsBridge {
|
||||
/** JSON `{top,right,bottom,left}` in CSS pixels. */
|
||||
get(): string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidInsets?: AndroidInsetsBridge;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce one edge to a non-negative finite number.
|
||||
*
|
||||
* Anything else becomes 0 rather than propagating: a `NaN` would serialise to
|
||||
* `"NaNpx"`, which invalidates the whole declaration and silently restores the
|
||||
* original bug.
|
||||
*/
|
||||
function edge(value: unknown): number {
|
||||
const n = typeof value === "string" ? Number(value) : value;
|
||||
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a native inset payload (JSON string or already-decoded object).
|
||||
* Returns `null` when the input is not an inset payload at all, so callers can
|
||||
* distinguish "no insets reported" from "insets are all zero".
|
||||
*/
|
||||
export function parseNativeInsets(raw: unknown): SafeAreaInsets | null {
|
||||
let value = raw;
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
if (!EDGES.some((e) => e in record)) return null;
|
||||
|
||||
return {
|
||||
top: edge(record.top),
|
||||
right: edge(record.right),
|
||||
bottom: edge(record.bottom),
|
||||
left: edge(record.left),
|
||||
};
|
||||
}
|
||||
|
||||
/** The CSS custom properties for a set of insets. */
|
||||
export function safeAreaCssVars(insets: SafeAreaInsets): Record<string, string> {
|
||||
return Object.fromEntries(EDGES.map((e) => [`--jt-inset-${e}`, `${insets[e]}px`]));
|
||||
}
|
||||
|
||||
/** Write the inset custom properties onto an element (normally `<html>`). */
|
||||
export function applySafeAreaInsets(target: HTMLElement, insets: SafeAreaInsets): void {
|
||||
for (const [name, value] of Object.entries(safeAreaCssVars(insets))) {
|
||||
target.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current insets from the native bridge, or `null` when there is none.
|
||||
*
|
||||
* Never throws. WebView can hand JS a *stale proxy* after a page load — the
|
||||
* injected object stays truthy while its methods vanish (the exact failure that
|
||||
* broke the background-audio toggle, see `MainActivity.configureWebViewForMedia`).
|
||||
* Layout init must survive that.
|
||||
*/
|
||||
export function readNativeInsets(): SafeAreaInsets | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const bridge = window.AndroidInsets;
|
||||
if (!bridge || typeof bridge.get !== "function") return null;
|
||||
|
||||
try {
|
||||
return parseNativeInsets(bridge.get());
|
||||
} catch (err) {
|
||||
console.warn("[SafeArea] AndroidInsets bridge unusable:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prime the safe-area custom properties and keep them current.
|
||||
*
|
||||
* Call once, early in the root layout's `onMount` (synchronously — before any
|
||||
* `await`, so the very first paint is already inset-correct). Returns a
|
||||
* teardown that unsubscribes.
|
||||
*
|
||||
* With no native bridge this is a near no-op: it deliberately does NOT write
|
||||
* `0px`, so the `env(safe-area-inset-*)` half of the `max()` still wins on iOS
|
||||
* and desktop.
|
||||
*/
|
||||
export function initSafeArea(target?: HTMLElement): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
|
||||
const el = target ?? document.documentElement;
|
||||
|
||||
const sync = () => {
|
||||
const insets = readNativeInsets();
|
||||
if (insets) applySafeAreaInsets(el, insets);
|
||||
};
|
||||
|
||||
sync();
|
||||
window.addEventListener(INSETS_CHANGED_EVENT, sync);
|
||||
return () => window.removeEventListener(INSETS_CHANGED_EVENT, sync);
|
||||
}
|
||||
Reference in New Issue
Block a user