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"); + }); +});