Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

<!-- iOS Safari PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="ChatMux" />

<!-- iOS Safari Icons (opaque: iOS renders transparency as black) -->
Expand Down
31 changes: 31 additions & 0 deletions src/components/app/AppContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,34 @@ test('capability refresh consumes the discovery roster without adding its own re
assert.match(hook, /onSessionsChangeRef\.current\?\.\(/);
assert.doesNotMatch(refreshCallback, /api\.externalSessions/);
});

test('standalone chrome keeps app UI below a separate opaque safe-area surface', () => {
const appContent = readFileSync(new URL('./AppContent.tsx', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../../index.css', import.meta.url), 'utf8');
const documentHtml = readFileSync(new URL('../../../index.html', import.meta.url), 'utf8');
const themeContext = readFileSync(new URL('../../contexts/ThemeContext.jsx', import.meta.url), 'utf8');
const opaqueChromeSources = [
'../main-content/view/subcomponents/MainContentStateView.tsx',
'../sidebar/view/subcomponents/SidebarContent.tsx',
'../sidebar/view/subcomponents/SidebarCollapsed.tsx',
].map((source) => readFileSync(new URL(source, import.meta.url), 'utf8'));

assert.match(appContent, /className="pwa-status-bar-surface"/);
assert.match(appContent, /className="app-shell fixed inset-0 flex bg-background"/);
assert.match(styles, /\.pwa-status-bar-surface\s*\{/);
assert.match(styles, /background: hsl\(var\(--background\)\)/);
assert.doesNotMatch(styles, /\.pwa-status-bar-surface[\s\S]{0,400}backdrop-filter/);
assert.match(documentHtml, /apple-mobile-web-app-status-bar-style" content="black"/);
assert.doesNotMatch(documentHtml, /black-translucent/);
assert.doesNotMatch(themeContext, /black-translucent/);
assert.match(styles, /body \.app-shell,[\s\S]*top: var\(--safe-area-inset-top\) !important/);
assert.match(styles, /left: var\(--safe-area-inset-left\) !important/);
assert.match(styles, /right: var\(--safe-area-inset-right\) !important/);
assert.match(styles, /\.fixed\.inset-0:not\(\.app-shell\)/);
assert.doesNotMatch(styles, /body\.pwa-mode \.fixed\.inset-0\s*\{/);
assert.doesNotMatch(styles, /\.pwa-header-safe[\s\S]{0,160}padding-top: 0/);
for (const source of opaqueChromeSources) {
assert.doesNotMatch(source, /backdrop-blur/);
assert.match(source, /bg-background/);
}
});
3 changes: 2 additions & 1 deletion src/components/app/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,8 @@ function AppContentInner() {
}, []);

return (
<div className="fixed inset-0 flex bg-background" style={{ bottom: 'var(--keyboard-height, 0px)' }}>
<div className="app-shell fixed inset-0 flex bg-background" style={{ bottom: 'var(--keyboard-height, 0px)' }}>
<div aria-hidden="true" className="pwa-status-bar-surface" />
{!isMobile ? (
<div className="h-full flex-shrink-0 border-r border-border/50">
<Sidebar {...sidebarProps} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default function MainContentStateView({ mode, isMobile, onMenuClick }: Ma
return (
<div className="flex h-full flex-col">
{isMobile && (
<div className="pwa-header-safe flex-shrink-0 border-b border-border/50 bg-background/80 p-2 backdrop-blur-sm sm:p-3">
<div className="pwa-header-safe flex-shrink-0 border-b border-border/50 bg-background p-2 sm:p-3">
<MobileMenuButton onMenuClick={onMenuClick} compact />
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function SidebarCollapsed({
t,
}: SidebarCollapsedProps) {
return (
<div className="flex h-full w-12 flex-col items-center gap-1 bg-background/80 py-3 backdrop-blur-sm">
<div className="flex h-full w-12 flex-col items-center gap-1 bg-background py-3">
{/* Expand button with brand logo */}
<button
onClick={onExpand}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export default function SidebarContent({
};

return (
<div className="flex h-full flex-col bg-background/80 backdrop-blur-sm md:w-72 md:select-none">
<div className="flex h-full flex-col bg-background md:w-72 md:select-none">
<SidebarHeader
isPWA={isPWA}
isMobile={isMobile}
Expand Down
2 changes: 1 addition & 1 deletion src/contexts/ThemeContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const ThemeProvider = ({ children }) => {
// Update iOS status bar style and theme color for dark mode
const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
if (statusBarMeta) {
statusBarMeta.setAttribute('content', 'black-translucent');
statusBarMeta.setAttribute('content', 'black');
}

const themeColorMeta = document.querySelector('meta[name="theme-color"]');
Expand Down
58 changes: 43 additions & 15 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,8 @@
--mobile-nav-padding: 20px;
--mobile-nav-total: calc(var(--mobile-nav-height) + var(--mobile-nav-padding) + env(safe-area-inset-bottom, 0px));

/* Header safe area dimensions */
--header-safe-area-top: env(safe-area-inset-top, 0px);
/* Header spacing below the standalone status-bar safe area. */
--header-base-padding: 8px;
--header-total-padding: calc(var(--header-safe-area-top) + var(--header-base-padding));
}

/* Fallback for older iOS versions */
Expand Down Expand Up @@ -202,12 +200,48 @@
overflow: hidden;
}

/* Adjust fixed inset positioning in PWA mode */
body.pwa-mode .fixed.inset-0 {
top: var(--header-total-padding);
left: var(--safe-area-inset-left);
right: var(--safe-area-inset-right);
bottom: 0;
/*
* Keep the native status-bar strip and app chrome in separate layers.
* The dedicated shell starts below the safe area and the strip is opaque,
* preventing standalone browser effects from blurring titles and controls.
*/
.pwa-status-bar-surface {
position: fixed;
z-index: 70;
top: 0;
left: 0;
right: 0;
height: 0;
pointer-events: none;
background: hsl(var(--background));
}

@supports (padding-top: env(safe-area-inset-top)) {
@media (display-mode: standalone) {
.pwa-status-bar-surface {
height: var(--safe-area-inset-top);
}

body .app-shell,
body .fixed.inset-0:not(.app-shell) {
/* Tailwind's inset-0 lives in the later utilities cascade layer. */
top: var(--safe-area-inset-top) !important;
left: var(--safe-area-inset-left) !important;
right: var(--safe-area-inset-right) !important;
}
}
}

/* JavaScript fallback for iOS navigator.standalone and Android app refs. */
body.pwa-mode .pwa-status-bar-surface {
height: var(--safe-area-inset-top);
}

body.pwa-mode .app-shell,
body.pwa-mode .fixed.inset-0:not(.app-shell) {
top: var(--safe-area-inset-top) !important;
left: var(--safe-area-inset-left) !important;
right: var(--safe-area-inset-right) !important;
}

/* Global transition defaults */
Expand Down Expand Up @@ -783,12 +817,6 @@
padding-top: var(--header-base-padding);
}

/* When PWA mode is detected by JavaScript */
body.pwa-mode .pwa-header-safe {
/* Reset padding since #root already handles safe area */
padding-top: 0px !important;
}

/* For mobile PWA, add bottom padding for better spacing */
@media screen and (max-width: 768px) {
body.pwa-mode .pwa-header-safe {
Expand Down