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 `` — 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).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).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(/]*>/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([]);
});
});