Files
openchamber/packages/ui/src/styles/mobile.css
T
Bohdan Triapitsyn 96c011a8ef feat(mobile): tablet layout pass and foldable-ready size class (#2569)
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.
2026-08-02 16:25:16 +03:00

708 lines
30 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
}