fix: consistent mobile surface dimming and overlay scrim

Use a dark scrim for MobileOverlayPanel instead of a translucent background so
it dims the rest of the screen like the sessions sheet.

Rework MobileSurfaceShell to mirror that structure: the scrim is the fixed
parent's background with a quick fade (keeping it on a compositing layer iOS
Safari clips to the viewport), and the sheet is a flex child sized to leave the
top safe area dimmed — dropping the old top shadow that pooled at the corners.

Mount Files/Changes/Settings only while open so each recomputes its
safe-area layout fresh, fixing intermittent top-inset dimming, and drop the
dim behind the header overflow menu to avoid a scrim-to-scrim flicker on
select.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 19:00:14 +03:00
parent 61765ca32c
commit f11d56d80c
3 changed files with 64 additions and 53 deletions
+50 -40
View File
@@ -99,7 +99,7 @@ const MobileOverflowMenu: React.FC<{
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
<button
type="button"
className="absolute inset-0 cursor-default bg-[rgb(0_0_0_/_0.25)]"
className="absolute inset-0 cursor-default"
aria-label={t('mobile.surface.closeAria')}
onClick={onClose}
/>
@@ -286,47 +286,57 @@ const MobileShell: React.FC = () => {
<MobileSessionsSheet open={sessionsSheetOpen} onOpenChange={setSessionsSheetOpen} />
) : null}
<MobileSurfaceShell
open={filesOpen}
onClose={() => setFilesOpen(false)}
ariaLabel={t('mobile.menu.files')}
headerless
>
<ErrorBoundary>
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
</ErrorBoundary>
</MobileSurfaceShell>
{/* Mounted only while open (like the sessions sheet) so each surface
computes its safe-area / fixed-position layout fresh on open. Keeping
them always-mounted left a stale startup layout, which made the
top-inset dimming appear only intermittently on iOS. */}
{filesOpen ? (
<MobileSurfaceShell
open
onClose={() => setFilesOpen(false)}
ariaLabel={t('mobile.menu.files')}
headerless
>
<ErrorBoundary>
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
</ErrorBoundary>
</MobileSurfaceShell>
) : null}
<MobileSurfaceShell
open={changesOpen}
onClose={closeChanges}
ariaLabel={t('mobile.menu.changes')}
headerless
>
<ErrorBoundary>
<MobileChangesSurface
onClose={closeChanges}
initialDiffPath={pendingChangesDiff?.path ?? null}
initialDiffStaged={pendingChangesDiff?.staged === true}
/>
</ErrorBoundary>
</MobileSurfaceShell>
{changesOpen ? (
<MobileSurfaceShell
open
onClose={closeChanges}
ariaLabel={t('mobile.menu.changes')}
headerless
>
<ErrorBoundary>
<MobileChangesSurface
onClose={closeChanges}
initialDiffPath={pendingChangesDiff?.path ?? null}
initialDiffStaged={pendingChangesDiff?.staged === true}
/>
</ErrorBoundary>
</MobileSurfaceShell>
) : null}
<MobileSurfaceShell
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
ariaLabel={t('mobile.menu.settings')}
headerless
>
<ErrorBoundary>
<SettingsView
forceMobile
isWindowed
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
onClose={() => setSettingsOpen(false)}
/>
</ErrorBoundary>
</MobileSurfaceShell>
{settingsOpen ? (
<MobileSurfaceShell
open
onClose={() => setSettingsOpen(false)}
ariaLabel={t('mobile.menu.settings')}
headerless
>
<ErrorBoundary>
<SettingsView
forceMobile
isWindowed
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
onClose={() => setSettingsOpen(false)}
/>
</ErrorBoundary>
</MobileSurfaceShell>
) : null}
</div>
</DedicatedMobileAppProvider>
);
+13 -12
View File
@@ -69,7 +69,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
return () => window.clearTimeout(id);
}
setEntered(false);
const id = window.setTimeout(() => setMounted(false), 220);
const id = window.setTimeout(() => setMounted(false), 300);
return () => window.clearTimeout(id);
}, [open]);
@@ -180,31 +180,32 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
return createPortal(
<div
className={cn(
'fixed inset-0 z-50 flex items-end',
'bg-[rgb(0_0_0_/_0.45)]',
'fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
// The opacity transition keeps the scrim on its own compositing layer,
// which iOS Safari clips to the viewport — without it, a static scrim
// bleeds the dim into the bottom toolbar overscroll zone. Quick fade so
// it still feels near-instant.
'transition-opacity duration-200 ease-out',
entered ? 'opacity-100' : 'opacity-0',
)}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
onClick={onClose}
>
<button
type="button"
className="absolute inset-0 cursor-default"
aria-label={t('mobile.surface.closeAria')}
onClick={onClose}
/>
{/* Sheet is a normal flex child — mirroring MobileOverlayPanel. */}
<section
ref={surfaceRef}
className="relative flex h-[100dvh] w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground shadow-[0_-12px_48px_rgb(0_0_0_/_0.35)] will-change-transform"
className="mt-auto flex min-h-0 w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground"
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
style={{
// Sized to leave the top safe area uncovered so the scrim dims it.
height: 'calc(100% - var(--oc-safe-area-top, 0px))',
transform: visualTransform,
transition: isDraggingRef.current
? 'none'
: 'transform 220ms cubic-bezier(0.32, 0.72, 0, 1)',
paddingTop: 'var(--oc-safe-area-top, 0px)',
: 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
}}
>
<div
@@ -70,7 +70,7 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
const content = (
<div
className="fixed inset-0 z-50 flex flex-col bg-background/70"
className="fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]"
role="dialog"
aria-modal="true"
onClick={onClose}