From 7604dc47f9ca7828adf3d8303be5184c0013abcb Mon Sep 17 00:00:00 2001
From: chengyongru <61816729+chengyongru@users.noreply.github.com>
Date: Sat, 22 Aug 2026 01:11:20 +0800
Subject: [PATCH] fix(webui): keep iOS PWA controls inside safe area (#5477)
* fix(webui): keep iOS PWA controls inside safe area
* fix(webui): blend iOS PWA chrome into dark canvas
* fix(webui): sync PWA chrome with app theme
---
webui/index.html | 26 +++++++++++++++-----
webui/public/manifest.json | 8 ++++--
webui/src/hooks/useTheme.ts | 7 ++++++
webui/src/tests/index-html.test.ts | 35 +++++++++++++++++++++++++++
webui/src/tests/useTheme.test.tsx | 39 ++++++++++++++++++++++++++++++
5 files changed, 107 insertions(+), 8 deletions(-)
create mode 100644 webui/src/tests/useTheme.test.tsx
diff --git a/webui/index.html b/webui/index.html
index bb4659303..95bcba358 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -4,7 +4,7 @@
-
-
+
@@ -49,7 +53,7 @@
}
html.dark body {
- background: #1a1a1a;
+ background: #303030;
color: #fafafa;
}
@@ -96,13 +100,23 @@
diff --git a/webui/public/manifest.json b/webui/public/manifest.json
index aedf679af..4abea4e9b 100644
--- a/webui/public/manifest.json
+++ b/webui/public/manifest.json
@@ -4,8 +4,12 @@
"description": "nanobot AI assistant",
"start_url": "/",
"display": "standalone",
- "background_color": "#161618",
- "theme_color": "#161618",
+ "background_color": "#ffffff",
+ "theme_color": "#ffffff",
+ "color_scheme_dark": {
+ "background_color": "#303030",
+ "theme_color": "#303030"
+ },
"orientation": "any",
"icons": [
{
diff --git a/webui/src/hooks/useTheme.ts b/webui/src/hooks/useTheme.ts
index d1771da3d..86939b435 100644
--- a/webui/src/hooks/useTheme.ts
+++ b/webui/src/hooks/useTheme.ts
@@ -25,6 +25,13 @@ function applyTheme(theme: Theme): void {
const root = document.documentElement;
if (theme === "dark") root.classList.add("dark");
else root.classList.remove("dark");
+
+ const themeColor = document.querySelector('meta[name="theme-color"]');
+ const color =
+ theme === "dark"
+ ? themeColor?.dataset.themeColorDark
+ : themeColor?.dataset.themeColorLight;
+ if (themeColor && color) themeColor.content = color;
}
export function useTheme(): {
diff --git a/webui/src/tests/index-html.test.ts b/webui/src/tests/index-html.test.ts
index 1ac5584a5..f00dec12c 100644
--- a/webui/src/tests/index-html.test.ts
+++ b/webui/src/tests/index-html.test.ts
@@ -12,4 +12,39 @@ describe("index.html", () => {
expect(viewport).not.toContain("user-scalable=no");
expect(viewport).not.toMatch(/maximum-scale\s*=\s*1(?:\.0)?(?:,|$)/);
});
+
+ it("lets iOS keep standalone content inside the safe area", () => {
+ const html = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
+ const viewport = html.match(/ {
+ const html = readFileSync(resolve(process.cwd(), "index.html"), "utf8");
+ const manifest = JSON.parse(
+ readFileSync(resolve(process.cwd(), "public/manifest.json"), "utf8"),
+ ) as {
+ background_color?: string;
+ theme_color?: string;
+ color_scheme_dark?: { background_color?: string; theme_color?: string };
+ };
+ const document = new DOMParser().parseFromString(html, "text/html");
+ const themeColor = document.querySelector('meta[name="theme-color"]');
+ const lightBodyBackground = html.match(/body\s*{[^}]*background:\s*([^;]+);/s)?.[1]?.trim();
+ const darkBodyBackground = html.match(
+ /html\.dark body\s*{[^}]*background:\s*([^;]+);/s,
+ )?.[1]?.trim();
+
+ expect(themeColor?.content).toBe("#ffffff");
+ expect(themeColor?.dataset.themeColorLight).toBe("#ffffff");
+ expect(themeColor?.dataset.themeColorDark).toBe("#303030");
+ expect(lightBodyBackground).toBe("#ffffff");
+ expect(darkBodyBackground).toBe("#303030");
+ expect(manifest.background_color).toBe("#ffffff");
+ expect(manifest.theme_color).toBe("#ffffff");
+ expect(manifest.color_scheme_dark?.background_color).toBe("#303030");
+ expect(manifest.color_scheme_dark?.theme_color).toBe("#303030");
+ });
});
diff --git a/webui/src/tests/useTheme.test.tsx b/webui/src/tests/useTheme.test.tsx
new file mode 100644
index 000000000..42c676983
--- /dev/null
+++ b/webui/src/tests/useTheme.test.tsx
@@ -0,0 +1,39 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { useTheme } from "@/hooks/useTheme";
+
+describe("useTheme", () => {
+ beforeEach(() => {
+ localStorage.removeItem("nanobot-webui.theme");
+ document.documentElement.classList.remove("dark");
+
+ const themeColor = document.createElement("meta");
+ themeColor.name = "theme-color";
+ themeColor.content = "#ffffff";
+ themeColor.dataset.themeColorLight = "#ffffff";
+ themeColor.dataset.themeColorDark = "#303030";
+ document.head.append(themeColor);
+ });
+
+ afterEach(() => {
+ document.querySelector('meta[name="theme-color"]')?.remove();
+ document.documentElement.classList.remove("dark");
+ localStorage.removeItem("nanobot-webui.theme");
+ });
+
+ it("keeps browser chrome in sync with the selected app theme", () => {
+ localStorage.setItem("nanobot-webui.theme", "dark");
+ const { result } = renderHook(useTheme);
+ const themeColor = document.querySelector('meta[name="theme-color"]');
+
+ expect(document.documentElement).toHaveClass("dark");
+ expect(themeColor?.content).toBe("#303030");
+
+ act(() => result.current.setTheme("light"));
+
+ expect(document.documentElement).not.toHaveClass("dark");
+ expect(themeColor?.content).toBe("#ffffff");
+ expect(localStorage.getItem("nanobot-webui.theme")).toBe("light");
+ });
+});