fix: iOS PWA safe areas, viewport stability, and keyboard/overlay choreography
- Add standalone-only safe-area padding for the composer (bottom floor + fullscreen top inset) and top toast offset; env() reports 0 on iOS 26 standalone so a fixed floor is required - Pin the mobile shell to 100lvh: WebKit leaves 100dvh stuck at the keyboard-shrunk value after dismissal - Clamp visual-viewport pinning to documentElement.clientHeight to guard against stale visualViewport metrics - Defer the composer blur flip (120ms) so taps on composer controls survive the keyboard-resize reflow; transition the bottom padding so the late flip reads as a slide, not a dip - Restore the keyboard after mobile overlays close: MobileOverlayPanel dispatches synchronous open/close events, ChatInput refocuses within the same gesture, holds focus through iOS's tap-settle dismissal, guards the pill collapse via DOM focus, and reveals the composer form above the keyboard (programmatic focus skips iOS's native reveal)
This commit is contained in:
@@ -1016,6 +1016,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
// so the chat compensates keyboard + composer height in a single motion.
|
// so the chat compensates keyboard + composer height in a single motion.
|
||||||
const [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false);
|
const [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false);
|
||||||
const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false);
|
const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false);
|
||||||
|
// Installed PWA (standalone): tapping a composer control while the keyboard
|
||||||
|
// is up blurs the textarea first, and the keyboard-resize reflow moves the
|
||||||
|
// control out from under the finger BEFORE iOS synthesizes the click — the
|
||||||
|
// tap dismisses the keyboard but the control's onClick never fires. Defer
|
||||||
|
// the blur-driven state flip so the pinned composer holds still through the
|
||||||
|
// tap; a refocus cancels it. Browser (pan mode) and Capacitor keep the
|
||||||
|
// immediate flip.
|
||||||
|
const mobileBlurTimerRef = React.useRef<number | null>(null);
|
||||||
|
React.useEffect(() => () => {
|
||||||
|
if (mobileBlurTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(mobileBlurTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const [mobileDictationActive, setMobileDictationActive] = React.useState(false);
|
const [mobileDictationActive, setMobileDictationActive] = React.useState(false);
|
||||||
const [mobileAttachMenuOpen, setMobileAttachMenuOpen] = React.useState(false);
|
const [mobileAttachMenuOpen, setMobileAttachMenuOpen] = React.useState(false);
|
||||||
const [mobileDraftPicker, setMobileDraftPicker] = React.useState<'project' | 'branch' | null>(null);
|
const [mobileDraftPicker, setMobileDraftPicker] = React.useState<'project' | 'branch' | null>(null);
|
||||||
@@ -4131,6 +4144,74 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|| mobileAttachMenuOpen
|
|| mobileAttachMenuOpen
|
||||||
|| issuePickerOpen
|
|| issuePickerOpen
|
||||||
|| prPickerOpen;
|
|| prPickerOpen;
|
||||||
|
// Installed PWA (standalone): a focus() from a bare timeout is outside the
|
||||||
|
// user gesture and iOS refuses to raise the keyboard for it (Safari
|
||||||
|
// in-browser is lenient). MobileOverlayPanel dispatches
|
||||||
|
// 'oc:mobile-overlay-closed' synchronously from the same React flush as
|
||||||
|
// the click that closed it — refocus right there, while the gesture is
|
||||||
|
// still live. Chained flows (attach menu → GitHub picker) set the skip ref
|
||||||
|
// so the keyboard doesn't flash open under the next overlay.
|
||||||
|
const mobilePickerDialogsOpenRef = React.useRef(false);
|
||||||
|
mobilePickerDialogsOpenRef.current = issuePickerOpen || prPickerOpen;
|
||||||
|
const skipNextOverlayCloseRestoreRef = React.useRef(false);
|
||||||
|
const openSheetCountRef = React.useRef(0);
|
||||||
|
const holdComposerFocusUntilRef = React.useRef(0);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isMobile || isCapacitorApp() || typeof window === 'undefined') return;
|
||||||
|
if (!window.matchMedia?.('(display-mode: standalone)')?.matches) return;
|
||||||
|
const handleOverlayOpened = () => {
|
||||||
|
openSheetCountRef.current += 1;
|
||||||
|
};
|
||||||
|
const handleOverlayClosed = () => {
|
||||||
|
// Counter instead of a DOM check: the close event fires from a
|
||||||
|
// layout-effect cleanup, when the closing sheet's portal nodes may
|
||||||
|
// still be attached — the DOM can't tell "this sheet going away"
|
||||||
|
// from "another sheet still up".
|
||||||
|
openSheetCountRef.current = Math.max(0, openSheetCountRef.current - 1);
|
||||||
|
if (skipNextOverlayCloseRestoreRef.current) {
|
||||||
|
skipNextOverlayCloseRestoreRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!restoreKeyboardAfterOverlayRef.current) return;
|
||||||
|
if (mobilePickerDialogsOpenRef.current) return;
|
||||||
|
if (openSheetCountRef.current > 0) return;
|
||||||
|
restoreKeyboardAfterOverlayRef.current = false;
|
||||||
|
// iOS can still dismiss the freshly-raised keyboard when the tap
|
||||||
|
// that closed the overlay finishes over non-input content — hold
|
||||||
|
// focus through that window (see the onBlur guard).
|
||||||
|
holdComposerFocusUntilRef.current = Date.now() + 600;
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
// The native focus lands mid-commit; React's delegated onFocus may
|
||||||
|
// not make it into this flush, leaving mobileComposerBusy false for
|
||||||
|
// a beat — enough for the pill-collapse timer to unmount the
|
||||||
|
// focused textarea and kill the rising keyboard. Set the state
|
||||||
|
// explicitly instead of relying on the synthetic event.
|
||||||
|
if (document.activeElement === textareaRef.current) {
|
||||||
|
setMobileTextareaFocused(true);
|
||||||
|
}
|
||||||
|
// iOS reveals a field above the keyboard only for user-initiated
|
||||||
|
// focus; a programmatic one leaves the composer parked behind it
|
||||||
|
// (the chat screen has no viewport pin of its own — the draft
|
||||||
|
// screen's pinned form ignores these no-op scrolls). Reveal once
|
||||||
|
// the keyboard has mostly risen, and again after it settles.
|
||||||
|
const reveal = () => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta || document.activeElement !== ta) return;
|
||||||
|
// Align the BOTTOM of the whole composer form with the visible
|
||||||
|
// bottom: 'nearest' on the textarea alone leaves the footer
|
||||||
|
// icon row parked behind the keyboard accessory bar.
|
||||||
|
(composerFormRef.current ?? ta).scrollIntoView({ block: 'end' });
|
||||||
|
};
|
||||||
|
window.setTimeout(reveal, 300);
|
||||||
|
window.setTimeout(reveal, 650);
|
||||||
|
};
|
||||||
|
window.addEventListener('oc:mobile-overlay-opened', handleOverlayOpened);
|
||||||
|
window.addEventListener('oc:mobile-overlay-closed', handleOverlayClosed);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('oc:mobile-overlay-opened', handleOverlayOpened);
|
||||||
|
window.removeEventListener('oc:mobile-overlay-closed', handleOverlayClosed);
|
||||||
|
};
|
||||||
|
}, [isMobile]);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
if (mobileOverlayOpen) {
|
if (mobileOverlayOpen) {
|
||||||
@@ -4168,6 +4249,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isMobile || !mobileComposerExpanded || mobileComposerBusy) return;
|
if (!isMobile || !mobileComposerExpanded || mobileComposerBusy) return;
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
|
// Authoritative DOM check: the React focus state can lag a
|
||||||
|
// programmatic refocus (overlay-close keyboard restore). Collapsing
|
||||||
|
// would unmount the focused textarea and kill the keyboard.
|
||||||
|
if (document.activeElement === textareaRef.current) return;
|
||||||
mobileExpandIntentRef.current = null;
|
mobileExpandIntentRef.current = null;
|
||||||
setMobileComposerExpanded(false);
|
setMobileComposerExpanded(false);
|
||||||
setExpandedInput(false);
|
setExpandedInput(false);
|
||||||
@@ -4189,6 +4274,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
root.classList.add('oc-browser-keyboard-open');
|
root.classList.add('oc-browser-keyboard-open');
|
||||||
} else {
|
} else {
|
||||||
root.classList.remove('oc-browser-keyboard-open');
|
root.classList.remove('oc-browser-keyboard-open');
|
||||||
|
// Installed PWA (standalone): after the keyboard dismisses, WebKit
|
||||||
|
// can leave the layout viewport stuck smaller / panned (content
|
||||||
|
// shifted up with a dead strip at the bottom) until something
|
||||||
|
// forces it to recompute. A zero scroll after the keyboard's exit
|
||||||
|
// animation settles snaps it back; harmless when nothing is stuck.
|
||||||
|
if (window.matchMedia?.('(display-mode: standalone)')?.matches) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (root.classList.contains('oc-browser-keyboard-open')) return;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
document.body.scrollTop = 0;
|
||||||
|
root.scrollTop = 0;
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return () => root.classList.remove('oc-browser-keyboard-open');
|
return () => root.classList.remove('oc-browser-keyboard-open');
|
||||||
}, [isMobile, mobileTextareaFocused]);
|
}, [isMobile, mobileTextareaFocused]);
|
||||||
@@ -4272,21 +4370,28 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
// alone — hide the header for the duration via a root class instead.
|
// alone — hide the header for the duration via a root class instead.
|
||||||
document.documentElement.classList.add('oc-browser-kb-fullscreen');
|
document.documentElement.classList.add('oc-browser-kb-fullscreen');
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
|
const top = Math.max(0, Math.floor(vv.offsetTop));
|
||||||
|
// Same stale-visualViewport guard as the draft pin below: when the
|
||||||
|
// layout viewport is keyboard-resized (interactive-widget), its
|
||||||
|
// clientHeight is the authoritative above-keyboard height.
|
||||||
|
const layoutHeight = document.documentElement.clientHeight;
|
||||||
form.style.position = 'fixed';
|
form.style.position = 'fixed';
|
||||||
form.style.left = '0';
|
form.style.left = '0';
|
||||||
form.style.right = '0';
|
form.style.right = '0';
|
||||||
form.style.top = `${Math.max(0, Math.floor(vv.offsetTop))}px`;
|
form.style.top = `${top}px`;
|
||||||
form.style.height = `${Math.floor(vv.height)}px`;
|
form.style.height = `${Math.floor(Math.min(vv.height, layoutHeight - top))}px`;
|
||||||
form.style.zIndex = '40';
|
form.style.zIndex = '40';
|
||||||
form.style.background = 'var(--background)';
|
form.style.background = 'var(--background)';
|
||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
vv.addEventListener('resize', apply);
|
vv.addEventListener('resize', apply);
|
||||||
vv.addEventListener('scroll', apply);
|
vv.addEventListener('scroll', apply);
|
||||||
|
window.addEventListener('resize', apply);
|
||||||
window.addEventListener('scroll', apply, true);
|
window.addEventListener('scroll', apply, true);
|
||||||
return () => {
|
return () => {
|
||||||
vv.removeEventListener('resize', apply);
|
vv.removeEventListener('resize', apply);
|
||||||
vv.removeEventListener('scroll', apply);
|
vv.removeEventListener('scroll', apply);
|
||||||
|
window.removeEventListener('resize', apply);
|
||||||
window.removeEventListener('scroll', apply, true);
|
window.removeEventListener('scroll', apply, true);
|
||||||
document.documentElement.classList.remove('oc-browser-kb-fullscreen');
|
document.documentElement.classList.remove('oc-browser-kb-fullscreen');
|
||||||
form.style.position = '';
|
form.style.position = '';
|
||||||
@@ -4332,7 +4437,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
let lastTop = Number.NaN;
|
let lastTop = Number.NaN;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
const track = () => {
|
const track = () => {
|
||||||
const top = Math.max(0, Math.floor(vv.offsetTop + vv.height - form.offsetHeight));
|
// iOS standalone (PWA) can serve stale visualViewport metrics after
|
||||||
|
// the keyboard rises (full pre-keyboard height, intermittently),
|
||||||
|
// parking the form behind the keyboard. When interactive-widget
|
||||||
|
// resizes the layout viewport, documentElement.clientHeight is the
|
||||||
|
// true above-keyboard bottom — anchor to whichever is smaller. In
|
||||||
|
// pan-mode browsers clientHeight stays full height, so the min
|
||||||
|
// keeps the visual-viewport anchor there.
|
||||||
|
const layoutBottom = document.documentElement.clientHeight;
|
||||||
|
const vvBottom = vv.offsetTop + vv.height;
|
||||||
|
const top = Math.max(0, Math.floor(Math.min(vvBottom, layoutBottom) - form.offsetHeight));
|
||||||
if (top !== lastTop) {
|
if (top !== lastTop) {
|
||||||
lastTop = top;
|
lastTop = top;
|
||||||
form.style.top = `${top}px`;
|
form.style.top = `${top}px`;
|
||||||
@@ -5046,13 +5160,54 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
|
if (mobileBlurTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(mobileBlurTimerRef.current);
|
||||||
|
mobileBlurTimerRef.current = null;
|
||||||
|
}
|
||||||
mobileExpandIntentRef.current = null;
|
mobileExpandIntentRef.current = null;
|
||||||
setMobileTextareaFocused(true);
|
setMobileTextareaFocused(true);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
|
// Focus hold after an overlay-close restore:
|
||||||
|
// iOS may retract the rising keyboard as the
|
||||||
|
// closing tap settles — take the focus right
|
||||||
|
// back instead of accepting the blur.
|
||||||
|
if (Date.now() < holdComposerFocusUntilRef.current) {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (ta) {
|
||||||
|
ta.focus();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (Date.now() < holdComposerFocusUntilRef.current
|
||||||
|
&& document.activeElement !== ta) {
|
||||||
|
ta.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
lastMobileBlurAtRef.current = Date.now();
|
lastMobileBlurAtRef.current = Date.now();
|
||||||
setMobileTextareaFocused(false);
|
const standalone = !isCapacitorApp()
|
||||||
|
&& window.matchMedia?.('(display-mode: standalone)')?.matches;
|
||||||
|
if (!standalone) {
|
||||||
|
setMobileTextareaFocused(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// See mobileBlurTimerRef: hold the pinned
|
||||||
|
// composer still until the tap's click has
|
||||||
|
// been delivered.
|
||||||
|
if (mobileBlurTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(mobileBlurTimerRef.current);
|
||||||
|
}
|
||||||
|
// 120ms is enough to outlive the tap's
|
||||||
|
// synthesized click (which lands within a
|
||||||
|
// few ms of the blur) while keeping the
|
||||||
|
// padding's return visually tied to the
|
||||||
|
// keyboard dismissal.
|
||||||
|
mobileBlurTimerRef.current = window.setTimeout(() => {
|
||||||
|
mobileBlurTimerRef.current = null;
|
||||||
|
setMobileTextareaFocused(false);
|
||||||
|
}, 120);
|
||||||
}}
|
}}
|
||||||
placeholder={currentSessionId || newSessionDraftOpen
|
placeholder={currentSessionId || newSessionDraftOpen
|
||||||
? inputMode === 'shell'
|
? inputMode === 'shell'
|
||||||
@@ -5330,6 +5485,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
// Hand-off to the picker: don't sync-restore the
|
||||||
|
// keyboard under the overlay that opens next frame.
|
||||||
|
skipNextOverlayCloseRestoreRef.current = true;
|
||||||
setMobileAttachMenuOpen(false);
|
setMobileAttachMenuOpen(false);
|
||||||
requestAnimationFrame(openIssuePicker);
|
requestAnimationFrame(openIssuePicker);
|
||||||
}}
|
}}
|
||||||
@@ -5341,6 +5499,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
skipNextOverlayCloseRestoreRef.current = true;
|
||||||
setMobileAttachMenuOpen(false);
|
setMobileAttachMenuOpen(false);
|
||||||
requestAnimationFrame(openPrPicker);
|
requestAnimationFrame(openPrPicker);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Synchronous close signal: this layout-effect cleanup runs inside the same
|
||||||
|
// React flush as the user click that closed the panel, so listeners (e.g.
|
||||||
|
// the keyboard-restore in ChatInput) can refocus an input while iOS still
|
||||||
|
// considers the gesture active — a deferred focus() would not raise the
|
||||||
|
// keyboard in an installed PWA.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
window.dispatchEvent(new Event('oc:mobile-overlay-opened'));
|
||||||
|
return () => {
|
||||||
|
window.dispatchEvent(new Event('oc:mobile-overlay-closed'));
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -680,6 +680,55 @@
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Installed PWA (standalone) safe areas. In the in-browser page Safari's own
|
||||||
|
chrome covers the home indicator and status bar, and the Capacitor shell has
|
||||||
|
its own choreography (--oc-app-bottom-safe + keyboard FLIP) — neither matches
|
||||||
|
display-mode: standalone, so this block is PWA-only and cannot regress them.
|
||||||
|
Standalone runs the page edge-to-edge (viewport-fit=cover), so the composer
|
||||||
|
needs real top/bottom safe-area room of its own. */
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
/* Bottom: reserve home-indicator room under the composer while the keyboard
|
||||||
|
is down. The generic .bottom-safe-area token is intentionally capped at
|
||||||
|
4px (visual tuck under the gradient overlay), which is not enough to keep
|
||||||
|
controls clear of the indicator / rounded corners. A fixed floor is
|
||||||
|
required: iOS 26 standalone reports env(safe-area-inset-bottom) as 0.
|
||||||
|
Cancel the room while the keyboard is up (the indicator area is behind the
|
||||||
|
keyboard, and the pinned form anchors by its offsetHeight). The class flip
|
||||||
|
is deliberately delayed (tap race, see ChatInput onBlur) — the transition
|
||||||
|
turns that late flip into a slide instead of a dip-then-pop. */
|
||||||
|
:root:not(.oc-capacitor-app) .oc-mobile-composer {
|
||||||
|
transition: padding-bottom 220ms cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not(.oc-capacitor-app):not(.oc-browser-keyboard-open) .oc-mobile-composer {
|
||||||
|
padding-bottom: calc(0.75rem + max(16px, var(--oc-safe-area-bottom, env(safe-area-inset-bottom, 0px)))) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fullscreen composer: ChatInput pins the form to the visual viewport from
|
||||||
|
y = visualViewport.offsetTop, which is 0 in standalone (no URL bar), so the
|
||||||
|
form's first row lands under the status bar / Dynamic Island. Pad the
|
||||||
|
content down into the safe region instead. */
|
||||||
|
:root.oc-browser-kb-fullscreen:not(.oc-capacitor-app) .oc-mobile-composer {
|
||||||
|
padding-top: calc(var(--oc-safe-area-top, env(safe-area-inset-top, 0px)) + 0.5rem) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS standalone can leave 100dvh stuck at the keyboard-shrunk value after
|
||||||
|
the keyboard hides (WebKit doesn't reliably restore the dynamic viewport),
|
||||||
|
which shifted the whole shell up by that leftover. Standalone has no
|
||||||
|
collapsing browser chrome, so the dynamic and large viewports are the same
|
||||||
|
thing — pin the shell to 100lvh, which always reports full screen height.
|
||||||
|
Initial geometry is identical (lvh == dvh on load). */
|
||||||
|
:root:not(.oc-capacitor-app) .h-\[100dvh\] {
|
||||||
|
height: 100lvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top toasts: same offset the Capacitor shell already applies — without it
|
||||||
|
they render under the clock / Dynamic Island. */
|
||||||
|
:root:not(.oc-capacitor-app) [data-sonner-toaster][data-y-position='top'] {
|
||||||
|
top: calc(var(--oc-safe-area-top, env(safe-area-inset-top, 0px)) + 16px) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes oc-composer-morph-fade {
|
@keyframes oc-composer-morph-fade {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user