The tablet ran the phone layout with a half-finished iPad draft on top: two custom sidebars, a leftover overflow menu, split Files/Changes header buttons, and phone-width sheets stretched across a 13" screen. This brings it onto the phone's navigation model and keeps only the differences a large screen earns. - Sessions are a persistent resizable left sidebar; the overflow menu is gone and its destinations moved into that sidebar's footer (connected instance, settings, pending web update) and into the workspace drawer. - The workspace (Changes / Files / Terminal / Notes / MCP) is the phone's drawer everywhere: a resizable right sidebar where the screen can host one (up to 900px) and the full-cover drawer otherwise, with its mounted panes — an open diff, an edited file, an attached terminal — surviving rotation. - Header dropdowns are anchored popovers: the recents switcher mirrors the usage overlay on the left, and its trigger is sized to the title rather than to the free width. - App-level pages (settings, instances, update, an opened plan) render as centered dialogs instead of covering the screen. - Overlays center on the chat column through published insets, so the model and directory pickers no longer sit off-centre; the directory picker also stops overriding the shared width clamp. - Wide chat layout applies to mobile surfaces, where a tablet chat column is finally wide enough for the setting to mean anything. The layout gate is a live size class rather than a device check, so Android tablets and foldables are covered by the same code: - `enabled` when the shortest viewport side is at le sw600dp). The short side is what makes this a size question instead of a device question — a phone reports ~360-430 whichev unfolded book foldable ~600+, and folding shut drops back under it. iPads also answer on identity, since iPadOS hands out od - `roomyForPanels` when landscape and at least 1000px wide, which is what it takes to host the sidebar, the panel and a readabl foldables miss it in BOTH orientations — their long side is barely wider than a tablet's short one — so they keep the portr Every consumer re-decides instead of remembering wha open sidebar closes if the device folds shut under it. iPad behaviour is unchanged: its landscape widths all clear the panel ones do not, exactly as the previous orientation check did. Hardware keyboards are read natively. iOS reports them through GCKeyboard, published to the web layer at document start and kep disconnect and foregrounding; the layer stops inferring once that answers. A single early publish was not enough — the connect no already-attached keyboard fires before the page exists, and GameController can populate late — so the state is re-published across resume. With a keyboard attached the draft screen keeps its starter chips and the composer never collapses; tablets skip the colla Runtimes with no native answer fall back to inferring it from the keyboard bridge, and only ever conclude "hardware" from silen Also: sidebar rows no longer sit on a differently ti footer is no longer clipped by an over-tall content box, the resize handles moved above the panes' own overlays so they can actu now-unreachable overflow menu, fullscreen terminal/MCP/notes surfaces and their locale key are deleted. Device behaviour is unverified — the tablet layout, keyboard bridge and the foldable size class have not been exercised on hardware, and the 600/1000 thresholds are derived fr rather than measured on a foldable.
708 lines
30 KiB
CSS
708 lines
30 KiB
CSS
/* Mobile Adaptations - All Devices */
|
||
|
||
/* General mobile improvements for all mobile devices */
|
||
@media (max-width: 1024px) {
|
||
/* Override CSS custom properties for mobile */
|
||
:root.mobile-pointer:not(.desktop-runtime) {
|
||
--text-markdown: 1rem;
|
||
--text-code: 0.875rem;
|
||
--text-ui-header: 0.9375rem;
|
||
--text-ui-label: 0.875rem;
|
||
--text-meta: 0.875rem;
|
||
--text-micro: 0.8125rem;
|
||
--text-settings-page-title: 1.125rem;
|
||
}
|
||
|
||
/* Force override with higher specificity */
|
||
:root.mobile-pointer:not(.desktop-runtime) * {
|
||
--text-markdown: 1rem !important;
|
||
--text-code: 0.875rem !important;
|
||
--text-ui-header: 0.9375rem !important;
|
||
--text-ui-label: 0.875rem !important;
|
||
--text-meta: 0.875rem !important;
|
||
--text-micro: 0.8125rem !important;
|
||
--text-settings-page-title: 1.125rem !important;
|
||
}
|
||
|
||
/* Additional override for elements using typography classes */
|
||
:root.mobile-pointer:not(.desktop-runtime) .typography-markdown,
|
||
:root.mobile-pointer:not(.desktop-runtime) .typography-code,
|
||
:root.mobile-pointer:not(.desktop-runtime) .typography-ui-header,
|
||
:root.mobile-pointer:not(.desktop-runtime) .typography-ui-label,
|
||
:root.mobile-pointer:not(.desktop-runtime) .typography-meta,
|
||
:root.mobile-pointer:not(.desktop-runtime) .typography-micro {
|
||
font-size: unset !important;
|
||
}
|
||
|
||
/* Fix font size for tool displays */
|
||
:root.mobile-pointer:not(.desktop-runtime) [class*="tool"],
|
||
:root.mobile-pointer:not(.desktop-runtime) [class*="code"] {
|
||
--text-code: 0.875rem !important;
|
||
font-size: var(--text-code) !important;
|
||
}
|
||
|
||
/* Improve touch targets for mobile */
|
||
:root.mobile-pointer:not(.desktop-runtime) button:not([role="radio"]):not([role="checkbox"]):not([role="switch"]),
|
||
:root.mobile-pointer:not(.desktop-runtime) .btn,
|
||
:root.mobile-pointer:not(.desktop-runtime) [role="button"],
|
||
/* Static chat tool rows: not interactive as a whole, but they must share
|
||
the 36px rhythm of the [role="button"] expandable/reasoning rows. */
|
||
:root.mobile-pointer:not(.desktop-runtime) .oc-static-tool-row {
|
||
min-height: 36px;
|
||
min-width: 36px;
|
||
}
|
||
|
||
/* Composer footer action buttons (sessions / attach / auto-accept): hug the
|
||
icon so the group stays tight. The container only renders in the mobile
|
||
JSX, so no pointer/runtime gating is needed; !important overrides both the
|
||
w-8 utility and the global mobile touch-target min-width regardless of
|
||
cascade layer or specificity. */
|
||
.composer-mobile-actions button {
|
||
width: 1.5rem !important;
|
||
min-width: 0 !important;
|
||
}
|
||
|
||
/* Improve input field touch targets */
|
||
:root.mobile-pointer:not(.desktop-runtime) input,
|
||
:root.mobile-pointer:not(.desktop-runtime) textarea,
|
||
:root.mobile-pointer:not(.desktop-runtime) select {
|
||
min-height: 36px;
|
||
}
|
||
|
||
/* Fix mobile text inputs */
|
||
:root.mobile-pointer:not(.desktop-runtime) input[type="text"],
|
||
:root.mobile-pointer:not(.desktop-runtime) input[type="search"],
|
||
:root.mobile-pointer:not(.desktop-runtime) input[type="email"],
|
||
:root.mobile-pointer:not(.desktop-runtime) input[type="password"],
|
||
:root.mobile-pointer:not(.desktop-runtime) textarea {
|
||
font-size: 16px !important; /* Prevents iOS zoom */
|
||
}
|
||
|
||
/* Prevent keyboard on non-input elements */
|
||
:root.mobile-pointer:not(.desktop-runtime)
|
||
button:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||
:root.mobile-pointer:not(.desktop-runtime) .btn,
|
||
:root.mobile-pointer:not(.desktop-runtime) [role="button"] {
|
||
-webkit-touch-callout: none;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
|
||
/* Specific fix for session buttons */
|
||
:root.mobile-pointer:not(.desktop-runtime) button[inputmode="none"] {
|
||
-webkit-touch-callout: none;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* Prevent system context menu on message content when using custom selection menu */
|
||
:root.mobile-pointer:not(.desktop-runtime) .message-content-text {
|
||
-webkit-touch-callout: none;
|
||
-webkit-user-select: text;
|
||
user-select: text;
|
||
}
|
||
|
||
|
||
/* Allow system context menu only when explicitly requested via the "More" button */
|
||
:root.mobile-pointer:not(.desktop-runtime) .message-content-text.show-system-menu {
|
||
-webkit-touch-callout: default;
|
||
}
|
||
|
||
/* Fix mobile scroll containers */
|
||
:root.mobile-pointer:not(.desktop-runtime) .overflow-hidden {
|
||
overflow-x: hidden !important;
|
||
overflow-y: auto !important;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
|
||
:root.mobile-pointer:not(.desktop-runtime) [data-page-scroll-lock="true"] {
|
||
overflow: hidden !important;
|
||
overscroll-behavior: none;
|
||
}
|
||
|
||
/* Keep markdown code blocks horizontally scrollable on mobile. */
|
||
:root.mobile-pointer:not(.desktop-runtime) .markdown-content [data-markdown="code-block-body"] {
|
||
overflow-x: auto !important;
|
||
overflow-y: hidden !important;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
|
||
/* Fix mobile viewport height */
|
||
:root.mobile-pointer:not(.desktop-runtime) {
|
||
height: 100%;
|
||
height: -webkit-fill-available;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Fix main layout container */
|
||
:root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen {
|
||
height: 100vh;
|
||
height: -webkit-fill-available;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Fix main content area */
|
||
:root.mobile-pointer:not(.desktop-runtime) .flex-1.overflow-hidden {
|
||
min-height: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
/* Fix chat container */
|
||
:root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-full {
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Ensure proper flex behavior */
|
||
:root.mobile-pointer:not(.desktop-runtime) .flex.flex-col {
|
||
min-height: 0;
|
||
}
|
||
|
||
}
|
||
|
||
/* iOS PWA Adaptations */
|
||
|
||
/* iOS safe area defaults (applies to PWA + Safari) */
|
||
@supports (-webkit-touch-callout: none) {
|
||
:root.device-mobile:not(.desktop-runtime),
|
||
:root.device-tablet:not(.desktop-runtime),
|
||
:root.mobile-pointer:not(.desktop-runtime) {
|
||
--oc-safe-area-top: env(safe-area-inset-top, 0);
|
||
--oc-safe-area-right: env(safe-area-inset-right, 0);
|
||
--oc-safe-area-bottom: env(safe-area-inset-bottom, 0);
|
||
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.35), 12px);
|
||
--oc-safe-area-left: env(safe-area-inset-left, 0);
|
||
}
|
||
|
||
:root.device-mobile:not(.desktop-runtime) .header-safe-area,
|
||
:root.device-tablet:not(.desktop-runtime) .header-safe-area,
|
||
:root.mobile-pointer:not(.desktop-runtime) .header-safe-area {
|
||
padding-top: var(--oc-safe-area-top);
|
||
}
|
||
|
||
:root.oc-capacitor-app [data-sonner-toaster][data-y-position='top'] {
|
||
top: calc(env(safe-area-inset-top, 0px) + 16px) !important;
|
||
}
|
||
}
|
||
|
||
/* Phase 1: iOS PWA safe area handling - Enhanced positioning approach */
|
||
@media (display-mode: standalone) {
|
||
/* iOS-specific safe area handling. The --oc-safe-area-* tokens and the
|
||
.header-safe-area padding are already set by the unconditional iOS block
|
||
above; only the standalone-specific rules live here. */
|
||
@supports (-webkit-touch-callout: none) {
|
||
:root.device-mobile:not(.desktop-runtime) .main-content-safe-area,
|
||
:root.device-tablet:not(.desktop-runtime) .main-content-safe-area,
|
||
:root.mobile-pointer:not(.desktop-runtime) .main-content-safe-area {
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
padding-left: var(--oc-safe-area-left);
|
||
padding-right: var(--oc-safe-area-right);
|
||
}
|
||
|
||
/* Safe area for bottom fixed/bottom-0 elements */
|
||
:root.device-mobile:not(.desktop-runtime) .bottom-safe-area,
|
||
:root.device-tablet:not(.desktop-runtime) .bottom-safe-area,
|
||
:root.mobile-pointer:not(.desktop-runtime) .bottom-safe-area {
|
||
padding-bottom: var(--oc-safe-area-bottom-visual) !important;
|
||
}
|
||
|
||
/* Fix iOS viewport issues */
|
||
:root.device-mobile:not(.desktop-runtime) .flex.flex-col.h-screen,
|
||
:root.device-tablet:not(.desktop-runtime) .flex.flex-col.h-screen,
|
||
:root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen {
|
||
min-height: 100vh;
|
||
min-height: -webkit-fill-available;
|
||
}
|
||
|
||
/* Prevent content overlap in iOS */
|
||
:root.device-mobile:not(.desktop-runtime) .flex-1.overflow-hidden,
|
||
:root.device-tablet:not(.desktop-runtime) .flex-1.overflow-hidden,
|
||
:root.mobile-pointer:not(.desktop-runtime) .flex-1.overflow-hidden {
|
||
position: relative;
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
|
||
/* Mobile typography improvements */
|
||
.typography-markdown {
|
||
font-size: 1rem !important;
|
||
}
|
||
|
||
.typography-code {
|
||
font-size: 0.875rem !important;
|
||
}
|
||
|
||
.typography-ui-header {
|
||
font-size: 0.9375rem !important;
|
||
}
|
||
|
||
.typography-ui-label {
|
||
font-size: 0.875rem !important;
|
||
}
|
||
|
||
.typography-meta {
|
||
font-size: 0.875rem !important;
|
||
}
|
||
|
||
.typography-micro {
|
||
font-size: 0.8125rem !important;
|
||
}
|
||
|
||
/* Fix font size for tool displays */
|
||
[class*="tool"], [class*="code"] {
|
||
font-size: 0.875rem !important;
|
||
}
|
||
}
|
||
|
||
/* Fallback for non-iOS standalone mode */
|
||
@supports not (-webkit-touch-callout: none) {
|
||
:root.device-mobile:not(.desktop-runtime),
|
||
:root.device-tablet:not(.desktop-runtime),
|
||
:root.mobile-pointer:not(.desktop-runtime) {
|
||
--oc-safe-area-top: env(safe-area-inset-top, 0);
|
||
--oc-safe-area-right: env(safe-area-inset-right, 0);
|
||
--oc-safe-area-bottom: env(safe-area-inset-bottom, 0);
|
||
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.35), 12px);
|
||
--oc-safe-area-left: env(safe-area-inset-left, 0);
|
||
}
|
||
|
||
:root.device-mobile:not(.desktop-runtime) .header-safe-area,
|
||
:root.device-tablet:not(.desktop-runtime) .header-safe-area,
|
||
:root.mobile-pointer:not(.desktop-runtime) .header-safe-area {
|
||
padding-top: var(--oc-safe-area-top);
|
||
}
|
||
|
||
:root.device-mobile:not(.desktop-runtime) .main-content-safe-area,
|
||
:root.device-tablet:not(.desktop-runtime) .main-content-safe-area,
|
||
:root.mobile-pointer:not(.desktop-runtime) .main-content-safe-area {
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
padding-left: var(--oc-safe-area-left);
|
||
padding-right: var(--oc-safe-area-right);
|
||
}
|
||
|
||
:root.device-mobile:not(.desktop-runtime) .bottom-safe-area,
|
||
:root.device-tablet:not(.desktop-runtime) .bottom-safe-area,
|
||
:root.mobile-pointer:not(.desktop-runtime) .bottom-safe-area {
|
||
padding-bottom: var(--oc-safe-area-bottom-visual) !important;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Phase 6: Home indicator blur gradient fix */
|
||
@media (display-mode: standalone) and (max-width: 768px) {
|
||
.pwa-dialog-content {
|
||
--pwa-dialog-padding: clamp(0.75rem, 3vw, 1.25rem);
|
||
top: auto;
|
||
/* Browsers that don't understand dvh ignore the second declaration and
|
||
keep the vh-based fallback. Newer browsers use 100dvh so Android
|
||
Chrome's collapsible URL bar doesn't push the dialog footer off-screen. */
|
||
max-height: calc(
|
||
100vh
|
||
- var(--oc-safe-area-top, 0px)
|
||
- var(--oc-safe-area-bottom-visual, 0px)
|
||
- 20px
|
||
);
|
||
max-height: calc(
|
||
100dvh
|
||
- var(--oc-safe-area-top, 0px)
|
||
- var(--oc-safe-area-bottom-visual, 0px)
|
||
- 20px
|
||
);
|
||
padding: var(--pwa-dialog-padding);
|
||
padding-top: calc(var(--pwa-dialog-padding) + (var(--oc-safe-area-top, 0px) * 0.35));
|
||
row-gap: clamp(0.75rem, 2vw, 1.25rem);
|
||
}
|
||
|
||
.pwa-dialog-content.pwa-compact {
|
||
--pwa-dialog-padding: clamp(0.5rem, 2vw, 0.85rem);
|
||
row-gap: clamp(0.45rem, 1.5vw, 0.8rem);
|
||
}
|
||
|
||
.pwa-dialog-content [data-slot="dialog-close"] {
|
||
top: calc(0.25rem + (var(--oc-safe-area-top, 0px) * 0.35));
|
||
right: var(--pwa-dialog-padding);
|
||
}
|
||
|
||
.pwa-dialog-content.pwa-compact [data-slot="dialog-close"] {
|
||
top: calc(0.2rem + (var(--oc-safe-area-top, 0px) * 0.3));
|
||
}
|
||
|
||
.pwa-dialog-content .settings-page-body {
|
||
max-width: 100%;
|
||
padding: 0.75rem 1rem;
|
||
row-gap: 0;
|
||
}
|
||
|
||
.pwa-dialog-content .settings-page-body > :not([hidden]) ~ :not([hidden]) {
|
||
margin-top: 0.85rem;
|
||
}
|
||
|
||
.pwa-dialog-content .directory-dialog-body {
|
||
padding: 0.5rem 0.75rem;
|
||
}
|
||
|
||
.pwa-dialog-content .directory-dialog-body .directory-grid {
|
||
gap: 0.6rem;
|
||
}
|
||
|
||
.pwa-overlay-panel {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.pwa-overlay-panel .pwa-overlay-scroll {
|
||
padding-bottom: calc(var(--oc-safe-area-bottom-visual, 0px) + 12px);
|
||
}
|
||
}
|
||
|
||
@media (display-mode: standalone) {
|
||
/* Home indicator overlay */
|
||
:root.device-mobile:not(.desktop-runtime) body::after,
|
||
:root.device-tablet:not(.desktop-runtime) body::after,
|
||
:root.mobile-pointer:not(.desktop-runtime) body::after {
|
||
content: '';
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: var(--oc-safe-area-bottom-visual, var(--oc-safe-area-bottom, env(safe-area-inset-bottom, 0)));
|
||
background: linear-gradient(
|
||
to top,
|
||
var(--background) 0%,
|
||
var(--background) 30%,
|
||
rgba(from var(--background) r g b / 0.8) 60%,
|
||
rgba(from var(--background) r g b / 0.3) 85%,
|
||
transparent 100%
|
||
);
|
||
z-index: 1000;
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Fallback for browsers without rgba(from ...) support */
|
||
@supports not (color: rgba(from white r g b / 0.5)) {
|
||
:root.device-mobile:not(.desktop-runtime) body::after,
|
||
:root.device-tablet:not(.desktop-runtime) body::after,
|
||
:root.mobile-pointer:not(.desktop-runtime) body::after {
|
||
background: linear-gradient(
|
||
to top,
|
||
var(--background) 0%,
|
||
var(--background) 30%,
|
||
rgba(21, 19, 19, 0.8) 60%, /* Dark theme fallback */
|
||
rgba(21, 19, 19, 0.3) 85%,
|
||
transparent 100%
|
||
);
|
||
}
|
||
|
||
/* Light theme fallback */
|
||
:root body::after {
|
||
background: linear-gradient(
|
||
to top,
|
||
var(--background) 0%,
|
||
var(--background) 30%,
|
||
rgba(248, 247, 243, 0.8) 60%, /* Light theme fallback */
|
||
rgba(248, 247, 243, 0.3) 85%,
|
||
transparent 100%
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Small app-wide bottom safe area for the native shell. The phone's rounded hardware
|
||
corners clip controls flush against the bottom edge, and the PWA's own safe-area
|
||
padding is gated behind display-mode: standalone — which the Capacitor WebView does
|
||
not match — so nothing reserves bottom room in the native app. Expose it as a token
|
||
so any native surface can consume it; the chat shell does so below. */
|
||
:root.oc-capacitor-app {
|
||
--oc-app-bottom-safe: max(16px, calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) * 0.5));
|
||
}
|
||
|
||
/* Android native safe areas. Every --oc-safe-area-* definition above is gated
|
||
behind -webkit-touch-callout (iOS) or display-mode: standalone, neither of
|
||
which matches the Capacitor Android WebView — so the Android app used to rely
|
||
entirely on StatusBar overlay:false insetting the WebView natively. Android 15
|
||
enforces edge-to-edge and ignores that call, leaving content under the status
|
||
bar. Capacitor injects the real insets as --safe-area-inset-* inline vars on
|
||
<html> (env() still reads 0 on WebViews without the safe-area passthrough
|
||
fix), so read those first and fall back to env(). On devices where the native
|
||
inset still applies, both sources report 0 — no double padding. */
|
||
:root.oc-capacitor-app.oc-platform-android {
|
||
--oc-safe-area-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
||
--oc-safe-area-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
|
||
--oc-safe-area-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||
--oc-safe-area-bottom-visual: clamp(0px, calc(var(--oc-safe-area-bottom) * 0.35), 12px);
|
||
--oc-safe-area-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
|
||
}
|
||
|
||
:root.oc-capacitor-app.oc-platform-android .header-safe-area {
|
||
padding-top: var(--oc-safe-area-top);
|
||
}
|
||
|
||
:root.oc-capacitor-app.oc-platform-android [data-sonner-toaster][data-y-position='top'] {
|
||
top: calc(var(--oc-safe-area-top) + 16px) !important;
|
||
}
|
||
|
||
/* Paint the document canvas with the theme background in the native app — the same
|
||
thing .desktop-runtime does for body/#root, which the Capacitor shell never got.
|
||
The status bar is overlaid (transparent), and in dark mode `color-scheme: dark`
|
||
makes the bare UA canvas dark, so any sliver not covered by content (notably the
|
||
area behind the status bar) bled through as a dark band at the top. It only showed
|
||
in dark mode, which is why it tracked the system theme. */
|
||
:root.oc-capacitor-app,
|
||
:root.oc-capacitor-app body,
|
||
:root.oc-capacitor-app #root {
|
||
background: var(--background) !important;
|
||
background-color: var(--background) !important;
|
||
}
|
||
|
||
/* Native (Capacitor) keyboard handling.
|
||
The Keyboard plugin runs in `resize: 'none'` mode so the WebView keeps its full
|
||
height; instead we shrink the app shell by the keyboard frame height. Two vars,
|
||
both set by useNativeMobileChrome: --oc-keyboard-inset is the keyboard's target
|
||
height from the moment `keyboardWillShow` fires (overlay surfaces below animate
|
||
against it as before), while --oc-kb-layout is the same height applied to the
|
||
shell's LAYOUT only at the choreography point where a resize is invisible.
|
||
Scoped to .oc-capacitor-app so the browser PWA keeps its dvh / interactive-widget
|
||
behaviour untouched.
|
||
|
||
The shell height is NOT transitioned. Animating `height` reflows the whole shell
|
||
every frame and forces the chat's ResizeObserver to chase the bottom on each of
|
||
those frames — the source of the visible micro-jitter on the composer/chat while
|
||
pinned. Instead the visual rise is transform-only (compositor) — a FLIP
|
||
choreography driven by useNativeMobileChrome:
|
||
|
||
show: shell keeps its full height for the whole 0.25s; the composer (and, when
|
||
pinned, the chat scroll container — see useChatAutoFollow) slides up via
|
||
inline transforms in sync with the keyboard; at the end the shell snaps to
|
||
its final height (--oc-kb-layout, one reflow) and the shift is removed
|
||
in the same frame — visually identical, so the swap is invisible.
|
||
hide: the shell snaps back to full height immediately (still hidden behind the
|
||
keyboard), the composer/content are FLIP-offset to their raised position
|
||
and slide down with the keyboard.
|
||
|
||
`keyboardWillShow` fires at the start of the iOS keyboard animation with the final
|
||
height, and the duration/curve mimic the native keyboard (≈0.25s,
|
||
cubic-bezier(0.38, 0.7, 0.125, 1)). (visualViewport live-tracking would be exact
|
||
but doesn't report under WKWebView's `resize: 'none'`, so this is the best signal.) */
|
||
:root.oc-capacitor-app .oc-mobile-app-shell {
|
||
height: calc(100dvh - var(--oc-kb-layout, 0px));
|
||
/* Reserve the bottom safe area only while the keyboard is down — when it's up the
|
||
inset cancels it out (the home indicator is hidden and the composer should sit
|
||
flush above the keyboard). The shell keeps its own bg behind this padding. */
|
||
padding-bottom: max(0px, calc(var(--oc-app-bottom-safe, 0px) - var(--oc-kb-layout, 0px)));
|
||
}
|
||
|
||
/* Portal surfaces (bottom sheets, overlay panels) render at <body> level, outside
|
||
the app shell, so they don't inherit the shell's keyboard inset. They're full-
|
||
height `fixed inset-0` scrims with a bottom-anchored (`mt-auto`) sheet, so raising
|
||
their bottom edge by the keyboard height shrinks scrim + sheet together and lifts
|
||
any input above the keyboard instead of hiding it underneath. The opacity term
|
||
preserves the scrim's enter fade (Tailwind's `transition-opacity` would otherwise
|
||
be overridden by this rule's `transition` shorthand). */
|
||
:root.oc-capacitor-app .oc-keyboard-inset-surface {
|
||
bottom: var(--oc-keyboard-inset, 0px);
|
||
transition: bottom 0.25s cubic-bezier(0.38, 0.7, 0.125, 1), opacity 0.2s ease-out;
|
||
}
|
||
/* While a panel is playing its enter animation, its bottom anchor snaps
|
||
instead of transitioning — opening an overlay typically dismisses the
|
||
keyboard in the same moment, and animating both made the entrance jerky. */
|
||
:root.oc-capacitor-app .oc-keyboard-inset-surface.oc-keyboard-inset-snap {
|
||
transition: opacity 0.2s ease-out;
|
||
}
|
||
|
||
/* Bottom-sheet overlays live inside a keyboard-inset surface whose bottom is
|
||
lifted by the keyboard height, but their max-height is expressed in 100dvh —
|
||
which does NOT shrink in the Capacitor app (native keyboard resize is off).
|
||
Without this cap a sheet with the keyboard raised (typing inside it) can
|
||
grow past the top safe area and slide under the notch/status bar. Subtract
|
||
both the keyboard inset and the top safe area from the available height. */
|
||
:root.oc-capacitor-app .pwa-overlay-panel {
|
||
max-height: calc(
|
||
100dvh
|
||
- var(--oc-keyboard-inset, 0px)
|
||
- max(0.75rem, env(safe-area-inset-top))
|
||
);
|
||
}
|
||
|
||
/* Full-screen scroll views (e.g. the connect/login screen) live outside the app
|
||
shell, so shrink them by the keyboard height the same way the shell does. Capping
|
||
the height (instead of min-height: 100dvh) is what makes overflow-y-auto actually
|
||
scroll, so a field near the bottom lifts above the keyboard rather than staying
|
||
hidden behind it. min-height: 0 neutralises the Tailwind min-h-dvh baseline. */
|
||
:root.oc-capacitor-app .oc-keyboard-fill-screen {
|
||
height: calc(100dvh - var(--oc-keyboard-inset, 0px));
|
||
min-height: 0;
|
||
transition: height 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
|
||
}
|
||
|
||
/* Keyboard slide choreography: the composer (and the draft title, see
|
||
.oc-draft-center note below) are moved with INLINE transform/transition set
|
||
by useNativeMobileChrome — WebKit does not reliably start transitions when a
|
||
transform changes via a CSS custom property, which parked the composer until
|
||
the keyboard finished. Only the sliding backdrop lives here. */
|
||
:root.oc-capacitor-app.oc-kb-animating .oc-mobile-composer {
|
||
/* While sliding, the composer passes over chat content that hasn't moved (user
|
||
scrolled up) — give it an opaque backdrop so text doesn't show through the
|
||
form's transparent padding. */
|
||
background: var(--background);
|
||
}
|
||
|
||
/* Constant-viewport trick for the chat scroller: when the keyboard settles the
|
||
shell shrinks by --oc-kb-layout, but the scroller keeps its full height by
|
||
extending that far below its (shrunken) region — behind the composer and the
|
||
keyboard — and converts the covered strip into its own bottom padding. For
|
||
the virtualizer NOTHING resizes across keyboard transitions: clientHeight is
|
||
constant, every row that can become visible is already mounted, and the
|
||
open/close reposition is a single cheap scrollTop change (the settle re-pin /
|
||
the clamp) instead of a viewport resize that mounts rows with a visible
|
||
pause on long chats. */
|
||
:root.oc-capacitor-app .oc-mobile-app-shell .chat-scroll {
|
||
/* The shell shrinks by the keyboard height BUT also gives up its bottom
|
||
safe-area padding at the same moment (see the shell's padding-bottom
|
||
formula) — so the scroller's parent only loses (keyboard − safe). Extend
|
||
by exactly that, or the settle grows clientHeight by the safe inset and
|
||
the pinned chat clamps one row down a beat after the keyboard opens. */
|
||
bottom: calc(-1 * max(var(--oc-kb-layout, 0px) - var(--oc-app-bottom-safe, 0px), 0px));
|
||
/* The padding leg is driven by its own variable so it can be applied at the
|
||
very START of the keyboard rise (with an immediate re-pin — the bottom of
|
||
the chat moves up as the keyboard begins, not after it finishes), while
|
||
the bottom-extension leg still tracks the shell's settle-time shrink. Both
|
||
legs equal the keyboard height once settled, so the settle snap is
|
||
geometry-neutral for the scroller. */
|
||
padding-bottom: var(--oc-kb-scroll-inset, var(--oc-kb-layout, 0px));
|
||
}
|
||
|
||
/* WKWebView draws the text caret as a native layer that ignores CSS transforms:
|
||
after the keyboard rise it visibly flies from the pre-keyboard position to
|
||
the final one. Hide it during the transition (+ UIKit's reposition lag,
|
||
see oc-kb-caret-hold timing in useNativeMobileChrome) and pop it back in. */
|
||
:root.oc-capacitor-app.oc-kb-caret-hold textarea,
|
||
:root.oc-capacitor-app.oc-kb-caret-hold input,
|
||
:root.oc-capacitor-app.oc-kb-caret-hold [contenteditable] {
|
||
caret-color: transparent;
|
||
}
|
||
|
||
/* The composer is a CodeMirror editor, so it is neither a textarea nor an
|
||
input, and its caret is not the native one either: drawSelection() hides
|
||
that and paints a .cm-cursor element instead. Hiding caret-color alone
|
||
would leave that element riding across the screen. */
|
||
:root.oc-capacitor-app.oc-kb-caret-hold .cm-cursor,
|
||
:root.oc-capacitor-app.oc-kb-caret-hold .cm-dropCursor {
|
||
opacity: 0;
|
||
}
|
||
|
||
/* On touch devices the composer re-enables the NATIVE caret with `!important`
|
||
(composerNativeSelectionTheme: iOS colours its selection handles from the
|
||
caret colour, so the caret cannot stay transparent). That declaration beats
|
||
the plain [contenteditable] rule above, so the caret-hold needs its own
|
||
heavier rule for the composer — !important plus more specificity than the
|
||
theme's `.cm-editor .cm-content` selector. */
|
||
:root.oc-capacitor-app.oc-kb-caret-hold .cm-editor .cm-content,
|
||
:root.oc-capacitor-app.oc-kb-caret-hold .cm-editor .cm-content .cm-line {
|
||
caret-color: transparent !important;
|
||
}
|
||
|
||
/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing
|
||
room above the home indicator), but that gap looks artificial sitting right above
|
||
the keyboard — so tighten it while the keyboard is open. Snaps at the start of the
|
||
show animation (a ~10px pre-slide change; transitioning it would reflow the chat
|
||
every frame). */
|
||
:root.oc-capacitor-app.oc-keyboard-open .oc-mobile-composer {
|
||
padding-bottom: 12px;
|
||
}
|
||
|
||
/* Draft starter chips leave when the full composer takes over — with the
|
||
composer expanded (and hence the keyboard up) there is only room for the
|
||
draft title. oc-composer-expanded is set in the SAME frame as the pill swap
|
||
(see useMobileComposerShell), so the chips leave together with the morph;
|
||
the keyboard classes remain as fallbacks for keyboard-up states that do not
|
||
go through the pill (and for mobile browsers). Instant show/hide (no squish
|
||
animation); the title's own keyboard compensation (.oc-draft-center below)
|
||
carries the smooth motion.
|
||
|
||
A tablet has room to keep them: `oc-keep-draft-starters` is set by the shell
|
||
(portrait, or any orientation with a hardware keyboard — see MobileApp) and
|
||
opts the whole draft screen out of the hiding below. */
|
||
:root:not(.oc-keep-draft-starters).oc-composer-expanded .oc-draft-starters,
|
||
:root:not(.oc-keep-draft-starters).oc-capacitor-app.oc-keyboard-open .oc-draft-starters,
|
||
:root:not(.oc-keep-draft-starters).oc-browser-keyboard-open .oc-draft-starters {
|
||
display: none;
|
||
}
|
||
|
||
/* The draft title (.oc-draft-center) is vertically centered, so it has no
|
||
scroll-pinning to compensate the keyboard like the chat does. It rides the
|
||
keyboard choreography with an INLINE transform of half the keyboard shift
|
||
(exactly how far the center moves after the shell snap) — see the kb-movers
|
||
list in useNativeMobileChrome. No CSS rules needed here; the class only
|
||
marks the element for the mover query. */
|
||
|
||
/* Mobile-browser fullscreen composer (visual-viewport pinned form in
|
||
ChatInput): the header would otherwise paint above it — the form can't
|
||
out-stack it from inside the composer wrapper's z-10 context. */
|
||
:root.oc-browser-kb-fullscreen .oc-mobile-header {
|
||
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) .oc-mobile-app-shell {
|
||
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 {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
/* Voice overlay variant: the overlay BACKGROUND appears immediately (it rides
|
||
the morphing shape), but its content stays hidden until the shape has mostly
|
||
finished growing, then fades in — no clipped controls mid-morph. Timings
|
||
follow the 340ms wrapper morph in ChatInput. */
|
||
.oc-composer-morph-content-fade > * {
|
||
animation: oc-composer-morph-fade 0.18s ease-out 0.26s both;
|
||
}
|