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:
@@ -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')}>
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
|
||||||
<button
|
<button
|
||||||
type="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')}
|
aria-label={t('mobile.surface.closeAria')}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
@@ -286,47 +286,57 @@ const MobileShell: React.FC = () => {
|
|||||||
<MobileSessionsSheet open={sessionsSheetOpen} onOpenChange={setSessionsSheetOpen} />
|
<MobileSessionsSheet open={sessionsSheetOpen} onOpenChange={setSessionsSheetOpen} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<MobileSurfaceShell
|
{/* Mounted only while open (like the sessions sheet) so each surface
|
||||||
open={filesOpen}
|
computes its safe-area / fixed-position layout fresh on open. Keeping
|
||||||
onClose={() => setFilesOpen(false)}
|
them always-mounted left a stale startup layout, which made the
|
||||||
ariaLabel={t('mobile.menu.files')}
|
top-inset dimming appear only intermittently on iOS. */}
|
||||||
headerless
|
{filesOpen ? (
|
||||||
>
|
<MobileSurfaceShell
|
||||||
<ErrorBoundary>
|
open
|
||||||
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
|
onClose={() => setFilesOpen(false)}
|
||||||
</ErrorBoundary>
|
ariaLabel={t('mobile.menu.files')}
|
||||||
</MobileSurfaceShell>
|
headerless
|
||||||
|
>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</MobileSurfaceShell>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<MobileSurfaceShell
|
{changesOpen ? (
|
||||||
open={changesOpen}
|
<MobileSurfaceShell
|
||||||
onClose={closeChanges}
|
open
|
||||||
ariaLabel={t('mobile.menu.changes')}
|
onClose={closeChanges}
|
||||||
headerless
|
ariaLabel={t('mobile.menu.changes')}
|
||||||
>
|
headerless
|
||||||
<ErrorBoundary>
|
>
|
||||||
<MobileChangesSurface
|
<ErrorBoundary>
|
||||||
onClose={closeChanges}
|
<MobileChangesSurface
|
||||||
initialDiffPath={pendingChangesDiff?.path ?? null}
|
onClose={closeChanges}
|
||||||
initialDiffStaged={pendingChangesDiff?.staged === true}
|
initialDiffPath={pendingChangesDiff?.path ?? null}
|
||||||
/>
|
initialDiffStaged={pendingChangesDiff?.staged === true}
|
||||||
</ErrorBoundary>
|
/>
|
||||||
</MobileSurfaceShell>
|
</ErrorBoundary>
|
||||||
|
</MobileSurfaceShell>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<MobileSurfaceShell
|
{settingsOpen ? (
|
||||||
open={settingsOpen}
|
<MobileSurfaceShell
|
||||||
onClose={() => setSettingsOpen(false)}
|
open
|
||||||
ariaLabel={t('mobile.menu.settings')}
|
onClose={() => setSettingsOpen(false)}
|
||||||
headerless
|
ariaLabel={t('mobile.menu.settings')}
|
||||||
>
|
headerless
|
||||||
<ErrorBoundary>
|
>
|
||||||
<SettingsView
|
<ErrorBoundary>
|
||||||
forceMobile
|
<SettingsView
|
||||||
isWindowed
|
forceMobile
|
||||||
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
|
isWindowed
|
||||||
onClose={() => setSettingsOpen(false)}
|
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
|
||||||
/>
|
onClose={() => setSettingsOpen(false)}
|
||||||
</ErrorBoundary>
|
/>
|
||||||
</MobileSurfaceShell>
|
</ErrorBoundary>
|
||||||
|
</MobileSurfaceShell>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</DedicatedMobileAppProvider>
|
</DedicatedMobileAppProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
}
|
}
|
||||||
setEntered(false);
|
setEntered(false);
|
||||||
const id = window.setTimeout(() => setMounted(false), 220);
|
const id = window.setTimeout(() => setMounted(false), 300);
|
||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -180,31 +180,32 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 flex items-end',
|
'fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
|
||||||
'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',
|
'transition-opacity duration-200 ease-out',
|
||||||
entered ? 'opacity-100' : 'opacity-0',
|
entered ? 'opacity-100' : 'opacity-0',
|
||||||
)}
|
)}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<button
|
{/* Sheet is a normal flex child — mirroring MobileOverlayPanel. */}
|
||||||
type="button"
|
|
||||||
className="absolute inset-0 cursor-default"
|
|
||||||
aria-label={t('mobile.surface.closeAria')}
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
<section
|
<section
|
||||||
ref={surfaceRef}
|
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}
|
tabIndex={-1}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
style={{
|
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,
|
transform: visualTransform,
|
||||||
transition: isDraggingRef.current
|
transition: isDraggingRef.current
|
||||||
? 'none'
|
? 'none'
|
||||||
: 'transform 220ms cubic-bezier(0.32, 0.72, 0, 1)',
|
: 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
Reference in New Issue
Block a user