diff --git a/AGENTS.md b/AGENTS.md
index 34c849f92..09f349344 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -175,6 +175,14 @@ as a first language.
- Comments should explain information the code cannot express clearly: intent, constraints, ownership, invariants, precedence, lifecycle, ordering, side effects, protocol shape, or non-obvious fallbacks.
- Do not add comments that only restate names, types, or visible operations.
+- Treat a contract comment as an explanation of the relationship between a producer and its consumers.
+- Explain why a value exists in the system before you explain how the code represents it.
+- Describe the decision, behavior, or invariant that a value controls.
+- If different values select different control-flow or UI paths, describe each observable outcome.
+- When a value crosses a module or component boundary, identify the consumer and how it applies the value.
+- Put representation details after behavior: units, coordinate systems, thresholds, clamps, and source API fields.
+- Put background evidence after the contract: browser behavior, issue links, investigation history, and removal conditions.
+- If the name, type, and surrounding code express the full contract, omit the comment.
- Place implementation comments next to the branch, calculation, transition, or side effect they explain.
- For calculation-heavy code, explain non-obvious coordinate systems, units, conversions, clamps, rounding, aggregation, and precedence beside the relevant intermediate values or branches.
- Prefer clearer names, types, and structured state over comments that compensate for hidden or encoded concepts.
diff --git a/apps/stage-web/src/pages/index.vue b/apps/stage-web/src/pages/index.vue
index 258b33355..79ff4972e 100644
--- a/apps/stage-web/src/pages/index.vue
+++ b/apps/stage-web/src/pages/index.vue
@@ -21,7 +21,7 @@ import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
-import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
+import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
const paused = ref(false)
@@ -31,6 +31,23 @@ function handleSettingsOpen(open: boolean) {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
+const stageViewportOffset = shallowRef(0)
+// WORKAROUND:
+// NOTICE:
+// Why: A fixed Stage follows Safari's input pan and moves Live2D with the keyboard.
+// Root cause: Safari moves the Visual Viewport before the page receives the new offsetTop value.
+// Source: https://bugs.webkit.org/show_bug.cgi?id=265578
+// Context: packages/stage-layouts/src/browser/adaptive-input.ts
+// Removal condition: Safari keeps fixed content stable during the input pan.
+const stageSurfaceStyle = computed(() => isMobile.value
+ ? {
+ position: 'fixed' as const,
+ inset: '0',
+ height: '100dvh',
+ transform: `translate3d(0, ${stageViewportOffset.value}px, 0)`,
+ willChange: 'transform',
+ }
+ : undefined)
const backgroundStore = useBackgroundStore()
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
@@ -183,9 +200,16 @@ const cursorPosition = computed(() => ({
ref="backgroundSurface"
class="widgets top-widgets"
:background="selectedOption"
+ :style="stageSurfaceStyle"
:top-color="sampledColor"
>
-
+
@@ -210,10 +234,17 @@ const cursorPosition = computed(() => ({
/>
-
+
+
+
diff --git a/packages/stage-layouts/package.json b/packages/stage-layouts/package.json
index 6fdc00503..f2fab5a1b 100644
--- a/packages/stage-layouts/package.json
+++ b/packages/stage-layouts/package.json
@@ -16,7 +16,9 @@
},
"exports": {
".": "./src/index.ts",
+ "./browser/adaptive-input": "./src/browser/adaptive-input.ts",
"./layouts/*": "./src/layouts/*.vue",
+ "./components/AdaptiveInput": "./src/components/AdaptiveInput/index.ts",
"./components/Layouts/*": "./src/components/Layouts/*.vue",
"./components/Layouts/InteractiveArea/Actions/*": "./src/components/Layouts/InteractiveArea/Actions/*.vue",
"./components/Layouts/ViewControls/*": "./src/components/Layouts/ViewControls/*.vue",
diff --git a/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts b/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts
new file mode 100644
index 000000000..0b46a1ed3
--- /dev/null
+++ b/packages/stage-layouts/src/browser/adaptive-input-geometry.test.ts
@@ -0,0 +1,243 @@
+import type { ViewportProfile, ViewportRectangle, ViewportSample } from './adaptive-input-geometry'
+
+import { describe, expect, it } from 'vitest'
+
+import {
+ calculateCachedViewportHeight,
+ calculateKeyboardShift,
+ calculateVisualViewportLayout,
+} from './adaptive-input-geometry'
+
+function createRectangle(options: {
+ bottom: number
+ left?: number
+ right?: number
+ top: number
+}): ViewportRectangle {
+ const left = options.left ?? 0
+ const right = options.right ?? 390
+
+ return {
+ bottom: options.bottom,
+ height: options.bottom - options.top,
+ left,
+ right,
+ top: options.top,
+ width: right - left,
+ }
+}
+
+function createViewportProfile(options: Partial = {}): ViewportProfile {
+ return {
+ displayMode: 'browser',
+ height: 714,
+ width: 390,
+ ...options,
+ }
+}
+
+function createViewportSample(options: Partial = {}): ViewportSample {
+ return {
+ bottomHiddenByKeyboard: 310,
+ measuredAt: 1_000,
+ profile: createViewportProfile(),
+ ...options,
+ }
+}
+
+describe('adaptive input geometry', () => {
+ it('moves a bottom input area above a docked keyboard', () => {
+ const target = createRectangle({ top: 780, bottom: 844 })
+ const keyboard = createRectangle({ top: 544, bottom: 844 })
+
+ expect(calculateKeyboardShift(target, keyboard)).toBe(300)
+ })
+
+ it('does not move the input area for a floating keyboard without horizontal overlap', () => {
+ const target = createRectangle({ top: 780, bottom: 844, left: 0, right: 180 })
+ const keyboard = createRectangle({ top: 500, bottom: 700, left: 210, right: 380 })
+
+ expect(calculateKeyboardShift(target, keyboard)).toBe(0)
+ })
+
+ it('provides the offset needed to counter the iOS visual viewport pan', () => {
+ const layout = calculateVisualViewportLayout({
+ height: 404,
+ offsetTop: 310,
+ pageTop: 310,
+ }, 714, 'focused')
+
+ expect(layout.height).toBe(404)
+ expect(layout.keyboardVisible).toBe(true)
+ expect(layout.offsetTop).toBe(310)
+ expect(layout.visibleBottom).toBe(714)
+ })
+
+ // https://bugs.webkit.org/show_bug.cgi?id=265578
+ it('keeps the chat bottom at the visual viewport bottom for WebKit bug 265578', () => {
+ // ROOT CAUSE:
+ //
+ // Safari can increase pageTop before it reports the final keyboard height.
+ // A calculation that uses height alone places the chat layer above the visible bottom edge.
+ //
+ // Before the fix, the chat layer used visualViewport.height - keyboardShift as its bottom edge.
+ //
+ // We fixed this by using height + pageTop as the bottom edge in document coordinates.
+ // Only the chat layer uses this value.
+ const layout = calculateVisualViewportLayout({
+ height: 404,
+ offsetTop: 310,
+ pageTop: 310,
+ }, 714, 'focused')
+
+ expect(layout.visibleBottom).toBe(714)
+ })
+
+ it('detects a keyboard when the browser resizes both viewports', () => {
+ const layout = calculateVisualViewportLayout({
+ height: 404,
+ offsetTop: 0,
+ pageTop: 0,
+ }, 714, 'focused')
+
+ expect(layout.keyboardVisible).toBe(true)
+ expect(layout.offsetTop).toBe(0)
+ expect(layout.visibleBottom).toBe(404)
+ })
+
+ it('does not treat browser controls as a keyboard', () => {
+ const layout = calculateVisualViewportLayout({
+ height: 654,
+ offsetTop: 0,
+ pageTop: 0,
+ }, 714, 'focused')
+
+ expect(layout.keyboardVisible).toBe(false)
+ expect(layout.visibleBottom).toBe(654)
+ })
+
+ // https://bugs.webkit.org/show_bug.cgi?id=265578
+ it('restores the input layout while Safari finishes closing the keyboard', () => {
+ // ROOT CAUSE:
+ //
+ // Safari keeps reporting the keyboard-sized Visual Viewport for part of its close animation.
+ // If AIRI uses that stale measurement after blur, the input region stays compressed and leaves
+ // a visible gap above the keyboard.
+ //
+ // Before the fix, blur disabled keyboardVisible but kept height and visibleBottom at 404px.
+ //
+ // We fixed this by restoring the stable layout height as soon as the owned input loses focus.
+ // Later Visual Viewport events cannot compress this input region again during keyboard close.
+ const layout = calculateVisualViewportLayout({
+ height: 404,
+ offsetTop: 0,
+ pageTop: 0,
+ }, 714, 'closing')
+
+ expect(layout.height).toBe(714)
+ expect(layout.heightLossExceedsThreshold).toBe(true)
+ expect(layout.keyboardVisible).toBe(false)
+ expect(layout.offsetTop).toBe(0)
+ expect(layout.visibleBottom).toBe(714)
+ })
+
+ it('keeps the Stage correction offset while Safari closes a panned viewport', () => {
+ const layout = calculateVisualViewportLayout({
+ height: 404,
+ offsetTop: 310,
+ pageTop: 310,
+ }, 714, 'closing')
+
+ expect(layout.offsetTop).toBe(310)
+ })
+
+ // https://craft.rkm.mx/b/1FD194A1-0DDD-4F79-B0FD-ABEC08F88A3F/iOS-%E9%94%AE%E7%9B%98%E9%9A%BE%E9%A2%98%E4%B8%8E%E5%8F%AF%E8%A7%81%E8%A7%86%E5%8F%A3%EF%BC%88VisualViewport%EF%BC%89API
+ it('calculates the pre-focus height from a previous keyboard measurement', () => {
+ // ROOT CAUSE:
+ //
+ // Safari starts its native page pan before it reports stable Visual Viewport geometry.
+ // A correction after focus cannot remove the compositor frames that move the Stage.
+ //
+ // Before the fix, AIRI waited for a Visual Viewport event before it changed the chat height.
+ //
+ // We fixed this by applying a recent height hidden by the keyboard before focus.
+ // The focused control is inside the predicted visible area when Safari starts its focus policy.
+ const cachedHeight = calculateCachedViewportHeight(
+ createViewportSample(),
+ createViewportProfile(),
+ 2_000,
+ )
+
+ expect(cachedHeight).toBe(404)
+ })
+
+ it('applies a recent height hidden by the keyboard to a small layout-height change', () => {
+ const cachedHeight = calculateCachedViewportHeight(
+ createViewportSample(),
+ createViewportProfile({ height: 734 }),
+ 2_000,
+ )
+
+ expect(cachedHeight).toBe(424)
+ })
+
+ it('rejects a cached measurement after an orientation change', () => {
+ const cachedHeight = calculateCachedViewportHeight(
+ createViewportSample(),
+ createViewportProfile({ height: 390, width: 844 }),
+ 2_000,
+ )
+
+ expect(cachedHeight).toBeUndefined()
+ })
+
+ it('rejects a cached measurement from another display mode', () => {
+ const cachedHeight = calculateCachedViewportHeight(
+ createViewportSample(),
+ createViewportProfile({ displayMode: 'standalone' }),
+ 2_000,
+ )
+
+ expect(cachedHeight).toBeUndefined()
+ })
+
+ it('rejects a cached measurement after a large layout-height change', () => {
+ const cachedHeight = calculateCachedViewportHeight(
+ createViewportSample(),
+ createViewportProfile({ height: 834 }),
+ 2_000,
+ )
+
+ expect(cachedHeight).toBeUndefined()
+ })
+
+ it('rejects a cached measurement from another layout width', () => {
+ const cachedHeight = calculateCachedViewportHeight(
+ createViewportSample(),
+ createViewportProfile({ width: 430 }),
+ 2_000,
+ )
+
+ expect(cachedHeight).toBeUndefined()
+ })
+
+ it('rejects a cached measurement without a keyboard-sized hidden height', () => {
+ const cachedHeight = calculateCachedViewportHeight(
+ createViewportSample({ bottomHiddenByKeyboard: 60 }),
+ createViewportProfile(),
+ 2_000,
+ )
+
+ expect(cachedHeight).toBeUndefined()
+ })
+
+ it('rejects an old cached measurement', () => {
+ const cachedHeight = calculateCachedViewportHeight(
+ createViewportSample(),
+ createViewportProfile(),
+ 3_601_000,
+ )
+
+ expect(cachedHeight).toBeUndefined()
+ })
+})
diff --git a/packages/stage-layouts/src/browser/adaptive-input-geometry.ts b/packages/stage-layouts/src/browser/adaptive-input-geometry.ts
new file mode 100644
index 000000000..7f36ea661
--- /dev/null
+++ b/packages/stage-layouts/src/browser/adaptive-input-geometry.ts
@@ -0,0 +1,180 @@
+/** A rectangle in layout viewport coordinates and CSS pixels. */
+export interface ViewportRectangle {
+ /** The bottom edge in CSS pixels. */
+ bottom: number
+ /** The rectangle height in CSS pixels. */
+ height: number
+ /** The left edge in CSS pixels. */
+ left: number
+ /** The right edge in CSS pixels. */
+ right: number
+ /** The top edge in CSS pixels. */
+ top: number
+ /** The rectangle width in CSS pixels. */
+ width: number
+}
+
+/** The viewport properties that determine whether a keyboard sample can be reused. */
+export interface ViewportProfile {
+ /** The browser or installed-app mode that owns the viewport policy. */
+ displayMode: 'browser' | 'standalone'
+ /** The layout viewport height in CSS pixels. */
+ height: number
+ /** The layout viewport width in CSS pixels. */
+ width: number
+}
+
+/** A measured keyboard overlap that can prepare a later focus operation. */
+export interface ViewportSample {
+ /** The layout viewport height hidden by the keyboard, in CSS pixels. */
+ bottomHiddenByKeyboard: number
+ /** The Unix timestamp in milliseconds when the sample was recorded. */
+ measuredAt: number
+ /** The viewport profile that produced the sample. */
+ profile: ViewportProfile
+}
+
+/** The Visual Viewport values used by the fallback layout policy. */
+export interface VisualViewportMeasurement {
+ /** The current visual viewport height in CSS pixels. */
+ height: number
+ /** The current visual viewport offsetTop in CSS pixels. */
+ offsetTop: number
+ /** The current visual viewport pageTop in CSS pixels. */
+ pageTop: number
+}
+
+/** The input lifecycle phase used to choose how one Visual Viewport measurement affects layout. */
+export type AdaptiveInputFocusPhase = 'idle' | 'focused' | 'closing'
+
+/** The resolved fallback layout for an adaptive input region. */
+export interface VisualViewportLayout {
+ /** The height to assign to keyboard-aware content, in CSS pixels. */
+ height: number
+ /**
+ * Whether to treat the height loss as keyboard-related.
+ *
+ * If true, a focused editable control can enable the keyboard-visible layout.
+ * If false, browser controls alone cannot enable the keyboard-visible layout.
+ */
+ heightLossExceedsThreshold: boolean
+ /** Whether to apply the keyboard-visible layout for this measurement. */
+ keyboardVisible: boolean
+ /** The translation that a separate visual layer can apply to cancel the Visual Viewport pan, in CSS pixels. */
+ offsetTop: number
+ /** The bottom edge to assign to the adaptive viewport, in document coordinates and CSS pixels. */
+ visibleBottom: number
+}
+
+/** Changes below this threshold can come from browser controls instead of a software keyboard. */
+const KEYBOARD_HEIGHT_LOSS_THRESHOLD = 100
+
+/** A sample expires before a long-lived tab can reuse geometry from an earlier keyboard mode. */
+const KEYBOARD_SAMPLE_MAX_AGE = 30 * 60 * 1000
+
+/** A larger height change invalidates the sample because the browser layout is no longer comparable. */
+const LAYOUT_HEIGHT_CHANGE_LIMIT = 80
+
+/** A width change larger than rounding noise identifies a different layout viewport. */
+const LAYOUT_WIDTH_CHANGE_LIMIT = 2
+
+/**
+ * Calculates a pre-focus viewport height from a recent compatible sample.
+ *
+ * The result is undefined when the sample is too old or its viewport profile is not compatible.
+ */
+export function calculateCachedViewportHeight(
+ sample: ViewportSample,
+ currentProfile: ViewportProfile,
+ now: number,
+): number | undefined {
+ const sampleAge = now - sample.measuredAt
+ if (sampleAge < 0 || sampleAge > KEYBOARD_SAMPLE_MAX_AGE)
+ return undefined
+
+ if (sample.profile.displayMode !== currentProfile.displayMode)
+ return undefined
+
+ const sampleIsLandscape = sample.profile.width > sample.profile.height
+ const currentIsLandscape = currentProfile.width > currentProfile.height
+ if (sampleIsLandscape !== currentIsLandscape)
+ return undefined
+
+ if (Math.abs(sample.profile.width - currentProfile.width) > LAYOUT_WIDTH_CHANGE_LIMIT)
+ return undefined
+
+ if (Math.abs(sample.profile.height - currentProfile.height) > LAYOUT_HEIGHT_CHANGE_LIMIT)
+ return undefined
+
+ if (sample.bottomHiddenByKeyboard <= KEYBOARD_HEIGHT_LOSS_THRESHOLD)
+ return undefined
+
+ const predictedHeight = currentProfile.height - sample.bottomHiddenByKeyboard
+ return predictedHeight > 0 ? predictedHeight : undefined
+}
+
+/**
+ * Resolves keyboard visibility and available edges from one Visual Viewport measurement.
+ *
+ * The reference height must remain stable while an editable control has focus.
+ */
+export function calculateVisualViewportLayout(
+ viewport: VisualViewportMeasurement,
+ referenceLayoutHeight: number,
+ focusPhase: AdaptiveInputFocusPhase,
+): VisualViewportLayout {
+ const heightLoss = Math.max(0, referenceLayoutHeight - viewport.height)
+ const heightLossExceedsThreshold = heightLoss > KEYBOARD_HEIGHT_LOSS_THRESHOLD
+ const viewportBottom = Math.max(0, viewport.height + viewport.pageTop)
+ const visibleBottom = referenceLayoutHeight > 0
+ ? Math.min(referenceLayoutHeight, viewportBottom)
+ : viewportBottom
+
+ if (focusPhase === 'closing') {
+ return {
+ height: referenceLayoutHeight,
+ heightLossExceedsThreshold,
+ keyboardVisible: false,
+ offsetTop: viewport.offsetTop,
+ visibleBottom: referenceLayoutHeight,
+ }
+ }
+
+ return {
+ height: viewport.height,
+ heightLossExceedsThreshold,
+ keyboardVisible: focusPhase === 'focused' && heightLossExceedsThreshold,
+ offsetTop: viewport.offsetTop,
+ visibleBottom,
+ }
+}
+
+/** Returns the upward distance needed to clear an overlapping keyboard rectangle. */
+export function calculateKeyboardShift(target: ViewportRectangle, keyboard: ViewportRectangle): number {
+ if (keyboard.width <= 0 || keyboard.height <= 0)
+ return 0
+
+ const hasHorizontalOverlap = target.left < keyboard.right && target.right > keyboard.left
+ if (!hasHorizontalOverlap)
+ return 0
+
+ return Math.max(0, target.bottom - keyboard.top)
+}
+
+/**
+ * Normalizes a DOM rectangle and adds a vertical offset.
+ *
+ * @example
+ * toViewportRectangle(new DOMRect(0, 10, 390, 64), 20)
+ * // => { top: 30, bottom: 94, left: 0, right: 390, width: 390, height: 64 }
+ */
+export function toViewportRectangle(rect: DOMRectReadOnly, verticalOffset = 0): ViewportRectangle {
+ return {
+ bottom: rect.bottom + verticalOffset,
+ height: rect.height,
+ left: rect.left,
+ right: rect.right,
+ top: rect.top + verticalOffset,
+ width: rect.width,
+ }
+}
diff --git a/packages/stage-layouts/src/browser/adaptive-input.ts b/packages/stage-layouts/src/browser/adaptive-input.ts
new file mode 100644
index 000000000..39beaf447
--- /dev/null
+++ b/packages/stage-layouts/src/browser/adaptive-input.ts
@@ -0,0 +1,441 @@
+import type { AdaptiveInputFocusPhase, ViewportProfile, ViewportSample } from './adaptive-input-geometry'
+
+import {
+ calculateCachedViewportHeight,
+ calculateKeyboardShift,
+ calculateVisualViewportLayout,
+ toViewportRectangle,
+} from './adaptive-input-geometry'
+
+/** The event that reports a new {@link AdaptiveInputLayout}. */
+export const ADAPTIVE_INPUT_LAYOUT_EVENT = 'layoutchange'
+
+/** The layout values produced for an adaptive input region. */
+export interface AdaptiveInputLayout {
+ /**
+ * Whether to use the keyboard-visible layout for the input region.
+ *
+ * If true, the input region must fit above a software keyboard.
+ * If false, the input region can use the normal viewport layout.
+ */
+ keyboardVisible: boolean
+ /** The height that remains visible above the keyboard, in CSS pixels. */
+ visibleHeight: number
+ /** The bottom edge to assign to the adaptive viewport, in document coordinates and CSS pixels. */
+ viewportBottom: number
+ /** The translation that a separate visual layer can apply to cancel the Visual Viewport pan, in CSS pixels. */
+ viewportOffsetTop: number
+}
+
+/** The elements and browser policy used by {@link AdaptiveInput}. */
+export interface AdaptiveInputOptions {
+ /** The region that contains editable controls and moves above the keyboard. */
+ area: HTMLElement
+ /** The region whose height follows the available viewport. */
+ viewport: HTMLElement
+ /**
+ * The window that owns the viewport measurements and browser events.
+ *
+ * @default window
+ */
+ window?: Window
+}
+
+const TEXT_ENTRY_SELECTOR = [
+ 'textarea',
+ 'input:not([type])',
+ 'input[type="email"]',
+ 'input[type="number"]',
+ 'input[type="password"]',
+ 'input[type="search"]',
+ 'input[type="tel"]',
+ 'input[type="text"]',
+ 'input[type="url"]',
+ '[contenteditable]:not([contenteditable="false"])',
+].join(',')
+
+function readViewportProfile(targetWindow: Window): ViewportProfile {
+ return {
+ displayMode: targetWindow.matchMedia('(display-mode: standalone)').matches
+ ? 'standalone'
+ : 'browser',
+ height: targetWindow.document.documentElement.clientHeight,
+ width: targetWindow.document.documentElement.clientWidth,
+ }
+}
+
+function isTextEntry(element: Element): boolean {
+ return element.matches(TEXT_ENTRY_SELECTOR)
+ && !element.matches(':disabled, [readonly]')
+}
+
+/**
+ * Owns keyboard measurements and focus timing for one adaptive input region.
+ *
+ * Construction starts event measurement. Call {@link dispose} when the owner releases the region.
+ * The controller reports each new layout through {@link ADAPTIVE_INPUT_LAYOUT_EVENT}.
+ *
+ * The Safari pre-focus path writes one synchronous inline height to `viewport`. This write must
+ * finish before `focus()`. The controller restores the previous height during disposal if the
+ * consumer has not replaced the value.
+ */
+export class AdaptiveInput extends EventTarget {
+ private readonly abortController = new AbortController()
+ private readonly area: HTMLElement
+ private readonly targetWindow: Window
+ private readonly viewport: HTMLElement
+ private readonly virtualKeyboard: (EventTarget & {
+ readonly boundingRect: DOMRectReadOnly
+ overlaysContent: boolean
+ }) | undefined
+
+ private readonly visualViewport: VisualViewport | null
+
+ private animationFrame: number | undefined
+ private focusPhase: AdaptiveInputFocusPhase = 'idle'
+ private layoutValue: AdaptiveInputLayout
+ private pendingWindowScrollRepair = false
+ private predictedViewportHeight: number | undefined
+ private predictionTimeout: number | undefined
+ private referenceLayoutHeight: number
+ private synchronousHeightBeforePrediction: string | undefined
+ private synchronousHeightValue: string | undefined
+ private viewportSample: ViewportSample | undefined
+
+ private readonly overlaysContentBeforeStart: boolean | undefined
+
+ constructor(options: AdaptiveInputOptions) {
+ super()
+
+ const targetWindow = options.window ?? window
+ const initialHeight = targetWindow.document.documentElement.clientHeight
+
+ this.area = options.area
+ this.targetWindow = targetWindow
+ this.viewport = options.viewport
+ this.visualViewport = targetWindow.visualViewport
+ this.referenceLayoutHeight = initialHeight
+ this.layoutValue = {
+ keyboardVisible: false,
+ visibleHeight: initialHeight,
+ viewportBottom: initialHeight,
+ viewportOffsetTop: 0,
+ }
+
+ // TypeScript 5.9 does not include this experimental browser API in lib.dom.d.ts.
+ this.virtualKeyboard = Reflect.get(targetWindow.navigator, 'virtualKeyboard')
+ this.overlaysContentBeforeStart = this.virtualKeyboard?.overlaysContent
+ if (this.virtualKeyboard)
+ this.virtualKeyboard.overlaysContent = true
+
+ const activeElement = targetWindow.document.activeElement
+ this.focusPhase = activeElement !== null
+ && this.area.contains(activeElement)
+ && isTextEntry(activeElement)
+ ? 'focused'
+ : 'idle'
+
+ const listenerOptions = { signal: this.abortController.signal }
+ this.area.addEventListener('pointerdown', this.onPointerDown, {
+ capture: true,
+ signal: this.abortController.signal,
+ })
+ targetWindow.document.addEventListener('focusin', this.onFocusIn, listenerOptions)
+ targetWindow.document.addEventListener('focusout', this.onFocusOut, listenerOptions)
+ this.virtualKeyboard?.addEventListener('geometrychange', this.requestMeasurement, listenerOptions)
+ this.visualViewport?.addEventListener('resize', this.requestMeasurement, listenerOptions)
+ this.visualViewport?.addEventListener('scroll', this.requestMeasurement, listenerOptions)
+ targetWindow.addEventListener('resize', this.requestMeasurement, listenerOptions)
+ targetWindow.addEventListener('orientationchange', this.onOrientationChange, listenerOptions)
+
+ this.requestMeasurement()
+ }
+
+ /** Returns the latest layout values. */
+ get layout(): Readonly {
+ return this.layoutValue
+ }
+
+ /** Stops browser events and restores browser values changed by this controller. */
+ dispose(): void {
+ this.abortController.abort()
+
+ if (this.animationFrame !== undefined)
+ this.targetWindow.cancelAnimationFrame(this.animationFrame)
+
+ if (this.predictionTimeout !== undefined)
+ this.targetWindow.clearTimeout(this.predictionTimeout)
+
+ if (this.virtualKeyboard && this.overlaysContentBeforeStart !== undefined)
+ this.virtualKeyboard.overlaysContent = this.overlaysContentBeforeStart
+
+ if (
+ this.synchronousHeightBeforePrediction !== undefined
+ && this.viewport.style.height === this.synchronousHeightValue
+ ) {
+ this.viewport.style.height = this.synchronousHeightBeforePrediction
+ }
+ }
+
+ private readonly requestMeasurement = () => {
+ if (this.animationFrame !== undefined)
+ return
+
+ this.animationFrame = this.targetWindow.requestAnimationFrame(this.measure)
+ }
+
+ private readonly measure = () => {
+ this.animationFrame = undefined
+
+ const currentProfile = readViewportProfile(this.targetWindow)
+ const currentLayoutHeight = currentProfile.height
+ if (this.focusPhase === 'idle' && !this.pendingWindowScrollRepair)
+ this.referenceLayoutHeight = currentLayoutHeight
+
+ const stableLayoutHeight = this.referenceLayoutHeight || currentLayoutHeight
+ let keyboardVisible = false
+ let visibleHeight = currentLayoutHeight
+ let viewportBottom = currentLayoutHeight
+ let viewportOffsetTop = 0
+ let updateViewportNow = false
+
+ if (this.visualViewport) {
+ // WORKAROUND:
+ // NOTICE:
+ // Why: Safari sends Visual Viewport changes after its compositor starts the input pan.
+ // Root cause: Safari browser tabs do not implement navigator.virtualKeyboard.
+ // Source: https://bugs.webkit.org/show_bug.cgi?id=265578
+ // Related WebKit issue: https://bugs.webkit.org/show_bug.cgi?id=297779#c23
+ // Code reference: https://github.com/Ajaxy/telegram-tt/blob/8b63941b230b3870accc442b5ef5ac95fc53c719/src/util/windowSize.ts#L11-L51
+ // Removal condition: Safari provides keyboard geometry before the compositor pan starts.
+ const viewportLayout = calculateVisualViewportLayout({
+ height: this.visualViewport.height,
+ offsetTop: this.visualViewport.offsetTop,
+ pageTop: this.visualViewport.pageTop,
+ }, stableLayoutHeight, this.focusPhase)
+
+ const predictedHeight = this.predictedViewportHeight
+ const predictionIsActive = predictedHeight !== undefined && this.focusPhase === 'focused'
+ if (predictionIsActive && !viewportLayout.heightLossExceedsThreshold) {
+ keyboardVisible = true
+ visibleHeight = predictedHeight
+ viewportBottom = predictedHeight
+ }
+ else {
+ keyboardVisible = viewportLayout.keyboardVisible
+ visibleHeight = viewportLayout.height
+ viewportBottom = viewportLayout.visibleBottom
+ viewportOffsetTop = viewportLayout.offsetTop
+
+ if (viewportLayout.keyboardVisible) {
+ this.viewportSample = {
+ bottomHiddenByKeyboard: Math.max(0, stableLayoutHeight - viewportLayout.height),
+ measuredAt: Date.now(),
+ profile: currentProfile,
+ }
+ }
+
+ if (predictionIsActive && viewportLayout.heightLossExceedsThreshold) {
+ this.predictedViewportHeight = undefined
+ if (this.predictionTimeout !== undefined)
+ this.targetWindow.clearTimeout(this.predictionTimeout)
+ this.predictionTimeout = undefined
+ updateViewportNow = true
+ }
+ }
+
+ if (viewportLayout.heightLossExceedsThreshold)
+ this.pendingWindowScrollRepair = true
+
+ if (this.pendingWindowScrollRepair && !viewportLayout.heightLossExceedsThreshold) {
+ // WORKAROUND:
+ // NOTICE:
+ // Why: Safari can leave the page scrolled after the keyboard closes.
+ // Root cause: Safari keeps the Visual Viewport offset after the height returns.
+ // Source: https://github.com/Ajaxy/telegram-tt/blob/8b63941b230b3870accc442b5ef5ac95fc53c719/src/components/middle/MiddleColumn.tsx#L380-L415
+ // Removal condition: Safari resets the page scroll when the keyboard closes.
+ if (this.visualViewport.offsetTop > 0 || this.visualViewport.pageTop > 0)
+ this.targetWindow.scrollTo({ top: 0 })
+
+ this.pendingWindowScrollRepair = false
+ }
+
+ if (this.focusPhase === 'closing' && !viewportLayout.heightLossExceedsThreshold)
+ this.focusPhase = 'idle'
+ }
+ else if (this.focusPhase === 'closing') {
+ this.focusPhase = 'idle'
+ }
+
+ if (this.focusPhase === 'focused' && this.virtualKeyboard?.overlaysContent && this.virtualKeyboard.boundingRect.height > 0) {
+ const shiftedAreaRect = this.area.getBoundingClientRect()
+ const appliedBottomInset = Math.max(0, currentLayoutHeight - this.layoutValue.viewportBottom)
+ // The rectangle includes the current upward shift. Add the inset to recover its layout position.
+ const areaRect = toViewportRectangle(shiftedAreaRect, appliedBottomInset)
+
+ // VirtualKeyboard.boundingRect reports the keyboard intersection with the viewport.
+ // Intersect both rectangles so a floating keyboard moves only the covered area.
+ // Specification: https://github.com/w3c/virtual-keyboard/blob/8ed1fe298ba42579647315988e5875715bb010af/index.html
+ // Code reference: https://github.com/GoogleChrome/samples/tree/1eef1eeb6048684020d1160499e552b79843d000/virtualkeyboard
+ const keyboardShift = calculateKeyboardShift(
+ areaRect,
+ toViewportRectangle(this.virtualKeyboard.boundingRect),
+ )
+ keyboardVisible = true
+ viewportBottom = Math.max(0, currentLayoutHeight - keyboardShift)
+ }
+
+ this.layoutValue = {
+ keyboardVisible,
+ visibleHeight,
+ viewportBottom,
+ viewportOffsetTop,
+ }
+ this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
+
+ if (updateViewportNow) {
+ if (this.synchronousHeightBeforePrediction === undefined)
+ this.synchronousHeightBeforePrediction = this.viewport.style.height
+ this.synchronousHeightValue = `${viewportBottom}px`
+ this.viewport.style.height = this.synchronousHeightValue
+ }
+ }
+
+ private readonly onFocusIn = (event: FocusEvent) => {
+ if (!(event.target instanceof Element))
+ throw new TypeError('The focusin event target must be an Element.')
+
+ if (!this.area.contains(event.target) || !isTextEntry(event.target))
+ return
+
+ this.focusPhase = 'focused'
+ this.referenceLayoutHeight = readViewportProfile(this.targetWindow).height
+ this.requestMeasurement()
+ }
+
+ private readonly onFocusOut = (event: FocusEvent) => {
+ if (!(event.target instanceof Element))
+ throw new TypeError('The focusout event target must be an Element.')
+
+ if (!this.area.contains(event.target) || !isTextEntry(event.target))
+ return
+
+ this.focusPhase = 'closing'
+ this.predictedViewportHeight = undefined
+ if (this.predictionTimeout !== undefined)
+ this.targetWindow.clearTimeout(this.predictionTimeout)
+ this.predictionTimeout = undefined
+
+ // NOTICE:
+ // Why: The input region must follow the keyboard as soon as its owned input loses focus.
+ // Root cause: Safari keeps the keyboard-sized Visual Viewport until its close animation ends.
+ // Source: https://bugs.webkit.org/show_bug.cgi?id=265578
+ // Removal condition: Safari reports each intermediate keyboard close frame through a keyboard API.
+ const normalHeight = this.referenceLayoutHeight || readViewportProfile(this.targetWindow).height
+ this.layoutValue = {
+ keyboardVisible: false,
+ visibleHeight: normalHeight,
+ viewportBottom: normalHeight,
+ viewportOffsetTop: this.visualViewport?.offsetTop ?? 0,
+ }
+ this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
+
+ if (this.synchronousHeightBeforePrediction === undefined)
+ this.synchronousHeightBeforePrediction = this.viewport.style.height
+ this.synchronousHeightValue = `${normalHeight}px`
+ this.viewport.style.height = this.synchronousHeightValue
+
+ this.requestMeasurement()
+ }
+
+ private readonly onOrientationChange = () => {
+ this.viewportSample = undefined
+ this.predictedViewportHeight = undefined
+ if (this.predictionTimeout !== undefined)
+ this.targetWindow.clearTimeout(this.predictionTimeout)
+ this.predictionTimeout = undefined
+ this.requestMeasurement()
+ }
+
+ private readonly onPointerDown = (event: PointerEvent) => {
+ if (this.virtualKeyboard || !this.visualViewport || !this.viewportSample)
+ return
+
+ if (event.defaultPrevented || !event.cancelable || !event.isPrimary || event.button !== 0 || event.pointerType === 'mouse')
+ return
+
+ if (!(event.target instanceof Element))
+ throw new TypeError('The pointerdown event target must be an Element.')
+
+ const editable = event.target.closest(TEXT_ENTRY_SELECTOR)
+ if (!editable || !this.area.contains(editable) || editable.matches(':disabled, [readonly]'))
+ return
+
+ if (!(editable instanceof HTMLElement))
+ throw new TypeError('The matched text-entry target must be an HTMLElement.')
+
+ if (this.targetWindow.document.activeElement === editable)
+ return
+
+ const currentProfile = readViewportProfile(this.targetWindow)
+ const cachedHeight = calculateCachedViewportHeight(this.viewportSample, currentProfile, Date.now())
+ if (cachedHeight === undefined)
+ return
+
+ // WORKAROUND:
+ // NOTICE:
+ // Why: The input region must clear the keyboard before Safari applies its focus pan.
+ // Root cause: Safari decides whether to pan the page before it reports keyboard geometry.
+ // Source: https://craft.rkm.mx/b/1FD194A1-0DDD-4F79-B0FD-ABEC08F88A3F/iOS-%E9%94%AE%E7%9B%98%E9%9A%BE%E9%A2%98%E4%B8%8E%E5%8F%AF%E8%A7%81%E8%A7%86%E5%8F%A3%EF%BC%88VisualViewport%EF%BC%89API
+ // Code reference: https://github.com/morethanwords/tweb/blob/b21491cfdec248127cfb6a1e6617e26826021ff4/src/helpers/dom/fixSafariStickyInput.ts#L1-L23
+ // Removal condition: Safari provides keyboard geometry before its focus policy runs.
+ event.preventDefault()
+ this.referenceLayoutHeight = currentProfile.height
+ this.predictedViewportHeight = cachedHeight
+ this.layoutValue = {
+ keyboardVisible: true,
+ visibleHeight: cachedHeight,
+ viewportBottom: cachedHeight,
+ viewportOffsetTop: 0,
+ }
+ this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
+
+ if (this.synchronousHeightBeforePrediction === undefined)
+ this.synchronousHeightBeforePrediction = this.viewport.style.height
+ this.synchronousHeightValue = `${cachedHeight}px`
+ this.viewport.style.height = this.synchronousHeightValue
+
+ this.viewport.getBoundingClientRect()
+ editable.focus({ preventScroll: true })
+
+ if (this.targetWindow.document.activeElement === editable) {
+ if (this.predictionTimeout !== undefined)
+ this.targetWindow.clearTimeout(this.predictionTimeout)
+
+ // WORKAROUND:
+ // NOTICE:
+ // Why: A cached height must not keep the viewport compressed when no software keyboard opens.
+ // Root cause: An external keyboard produces no keyboard-sized Visual Viewport event.
+ // Code context: The pre-focus pointer handler applies a cached height before focus.
+ // Removal condition: Browsers expose keyboard visibility before focus.
+ this.predictionTimeout = this.targetWindow.setTimeout(() => {
+ this.predictedViewportHeight = undefined
+ this.predictionTimeout = undefined
+ this.requestMeasurement()
+ }, 1_000)
+ return
+ }
+
+ this.predictedViewportHeight = undefined
+ this.layoutValue = {
+ keyboardVisible: false,
+ visibleHeight: currentProfile.height,
+ viewportBottom: currentProfile.height,
+ viewportOffsetTop: 0,
+ }
+ this.dispatchEvent(new Event(ADAPTIVE_INPUT_LAYOUT_EVENT))
+ this.synchronousHeightValue = `${currentProfile.height}px`
+ this.viewport.style.height = this.synchronousHeightValue
+ this.requestMeasurement()
+ }
+}
diff --git a/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-area.vue b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-area.vue
new file mode 100644
index 000000000..c68f227ff
--- /dev/null
+++ b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-area.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-context.ts b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-context.ts
new file mode 100644
index 000000000..90ba7f8a9
--- /dev/null
+++ b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-context.ts
@@ -0,0 +1,16 @@
+import type { ComputedRef, Ref } from 'vue'
+
+import { createContext } from 'reka-ui'
+
+interface AdaptiveInputRootContext {
+ area: Ref
+ enabled: ComputedRef
+ keyboardVisible: Readonly[>
+ setArea: (element: HTMLElement | null) => void
+ setViewport: (element: HTMLElement | null) => void
+ viewport: Ref
+ viewportBottom: Readonly][>
+}
+
+export const [injectAdaptiveInputRootContext, provideAdaptiveInputRootContext]
+ = createContext('AdaptiveInputRoot')
diff --git a/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-root.vue b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-root.vue
new file mode 100644
index 000000000..00acd2b83
--- /dev/null
+++ b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-root.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
diff --git a/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-viewport.vue b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-viewport.vue
new file mode 100644
index 000000000..2367d6d6a
--- /dev/null
+++ b/packages/stage-layouts/src/components/AdaptiveInput/adaptive-input-viewport.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/stage-layouts/src/components/AdaptiveInput/index.ts b/packages/stage-layouts/src/components/AdaptiveInput/index.ts
new file mode 100644
index 000000000..944e4c8bd
--- /dev/null
+++ b/packages/stage-layouts/src/components/AdaptiveInput/index.ts
@@ -0,0 +1,8 @@
+export { default as AdaptiveInputArea } from './adaptive-input-area.vue'
+export type { AdaptiveInputAreaProps } from './adaptive-input-area.vue'
+
+export { default as AdaptiveInputRoot } from './adaptive-input-root.vue'
+export type { AdaptiveInputRootProps } from './adaptive-input-root.vue'
+
+export { default as AdaptiveInputViewport } from './adaptive-input-viewport.vue'
+export type { AdaptiveInputViewportProps } from './adaptive-input-viewport.vue'
diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
index cf6b0281f..ccd713459 100644
--- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
+++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
@@ -16,9 +16,8 @@ import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea, useTheme } from '@proj-airi/ui'
-import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { storeToRefs } from 'pinia'
-import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
+import { computed, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
@@ -26,11 +25,29 @@ import ViewControls from '../Layouts/InteractiveArea/Actions/ViewControls.vue'
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
import ActionAbout from './InteractiveArea/Actions/About.vue'
+import { useMobileInteractiveAreaLayout } from '../../composables/use-mobile-interactive-area-layout'
import { useTranscriptions } from '../../composables/use-transcriptions'
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
import { BackgroundDialogPicker } from '../Backgrounds'
+interface Props {
+ /**
+ * Enables keyboard measurement and limits the chat layer to the visible viewport.
+ *
+ * @default false
+ */
+ keyboardAvoidance?: boolean
+}
+
+const props = withDefaults(defineProps(), {
+ keyboardAvoidance: false,
+})
+const emit = defineEmits<{
+ /** Sends visualViewport.offsetTop so the parent can keep the Stage at the same screen position. */
+ viewportOffsetChange: [offsetTop: number]
+}>()
+
const { isDark, toggleDark } = useTheme()
const chatOrchestrator = useChatStore()
const chatSession = useChatSessionStore()
@@ -73,13 +90,51 @@ function handleCleanupMessages() {
})
}
-const messageInput = ref('')
-const isComposing = ref(false)
-const backgroundDialogOpen = ref(false)
-const sessionsDrawerOpen = ref(false)
+const messageInput = shallowRef('')
+const isComposing = shallowRef(false)
+const backgroundDialogOpen = shallowRef(false)
+const sessionsDrawerOpen = shallowRef(false)
+const mobileInteractiveArea = useTemplateRef('mobileInteractiveArea')
+const messageComposer = useTemplateRef('messageComposer')
+const interactionControls = useTemplateRef('interactionControls')
+const controlsIsland = useTemplateRef('controlsIsland')
+const controlsIslandContent = useTemplateRef('controlsIslandContent')
+const {
+ chatHistoryStyle,
+ controlsIslandOverflowing,
+ controlsIslandStyle,
+ messageComposerStyle,
+ viewportOffsetTop,
+ viewportStyle: mobileInteractiveAreaStyle,
+} = useMobileInteractiveAreaLayout({
+ area: interactionControls,
+ controlsIsland,
+ controlsIslandContent,
+ enabled: () => props.keyboardAvoidance,
+ messageComposer,
+ viewport: mobileInteractiveArea,
+})
-const screenSafeArea = useScreenSafeArea()
-useResizeObserver(document.documentElement, () => screenSafeArea.update())
+watch(viewportOffsetTop, offsetTop => emit('viewportOffsetChange', offsetTop), { immediate: true })
+
+const mobileInteractiveAreaClass = computed(() => [
+ 'pointer-events-none fixed inset-x-0 z-20 w-full',
+ 'flex flex-col',
+ props.keyboardAvoidance ? 'top-0' : 'bottom-0',
+])
+const chatHistoryClass = computed(() => [
+ 'pointer-events-auto relative z-20',
+ 'max-w-[calc(100%_-_3.5rem)] w-full self-start pb-3 pl-3',
+ props.keyboardAvoidance ? undefined : 'max-h-[35dvh]',
+])
+const controlsIslandClass = computed(() => [
+ 'controls-island-scroll absolute right-0 translate-y-[-100%]',
+ 'max-w-full overflow-y-auto overscroll-contain px-3 py-3 font-sans scrollbar-none',
+ 'transition-[height] duration-250 ease-out',
+ controlsIslandOverflowing.value
+ ? 'controls-island-scroll--overflowing'
+ : undefined,
+])
const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
@@ -166,42 +221,71 @@ watch([enabled, stream], () => {
onUnmounted(() => {
teardownAnalyzer()
})
-
-onMounted(() => {
- screenSafeArea.update()
-})
- ]
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
+
{
}
.chat-history {
- max-height: 35dvh;
+ --gradient: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%);
+ -webkit-mask-image: var(--gradient);
+ mask-image: var(--gradient);
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: bottom;
+ mask-position: bottom;
+}
+
+.controls-island-scroll--overflowing {
+ --controls-island-mask: linear-gradient(
+ to bottom,
+ transparent 0,
+ black 1rem,
+ black calc(100% - 1rem),
+ transparent 100%
+ );
+
+ -webkit-mask-image: var(--controls-island-mask);
+ mask-image: var(--controls-island-mask);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
}
diff --git a/packages/stage-layouts/src/composables/use-adaptive-input.ts b/packages/stage-layouts/src/composables/use-adaptive-input.ts
new file mode 100644
index 000000000..72069711f
--- /dev/null
+++ b/packages/stage-layouts/src/composables/use-adaptive-input.ts
@@ -0,0 +1,97 @@
+import type { ConfigurableWindow, MaybeComputedElementRef } from '@vueuse/core'
+import type { MaybeRefOrGetter } from 'vue'
+
+import type { AdaptiveInputLayout } from '../browser/adaptive-input'
+
+import { defaultWindow, unrefElement, useEventListener, useWindowSize } from '@vueuse/core'
+import { computed, readonly, shallowReactive, shallowRef, toRefs, toValue, watch } from 'vue'
+
+import { ADAPTIVE_INPUT_LAYOUT_EVENT, AdaptiveInput } from '../browser/adaptive-input'
+
+/** The element targets and policy consumed by {@link useAdaptiveInput}. */
+export interface UseAdaptiveInputOptions extends ConfigurableWindow {
+ /**
+ * Enables keyboard measurement and layout updates.
+ *
+ * @default true
+ */
+ enabled?: MaybeRefOrGetter
+ /** The region that contains editable controls and moves above the keyboard. */
+ area: MaybeComputedElementRef
+ /** The region whose height follows the available viewport. */
+ viewport: MaybeComputedElementRef
+}
+
+/**
+ * Exposes reactive layout values from the framework-free {@link AdaptiveInput} controller.
+ *
+ * The composable owns the controller while both element targets are available. It does not move
+ * unrelated visual layers. Consumers decide how to use `viewportOffsetTop`.
+ */
+export function useAdaptiveInput(options: UseAdaptiveInputOptions) {
+ const targetWindow = options.window ?? defaultWindow
+ const enabled = computed(() => options.enabled === undefined || toValue(options.enabled))
+ const area = computed(() => unrefElement(options.area))
+ const viewport = computed(() => unrefElement(options.viewport))
+ const controller = shallowRef()
+ const { height: layoutViewportHeight } = useWindowSize({
+ includeScrollbar: false,
+ initialHeight: 0,
+ window: targetWindow,
+ })
+ const layout = shallowReactive({
+ keyboardVisible: false,
+ visibleHeight: layoutViewportHeight.value,
+ viewportBottom: layoutViewportHeight.value,
+ viewportOffsetTop: 0,
+ })
+
+ useEventListener(controller, ADAPTIVE_INPUT_LAYOUT_EVENT, () => {
+ const currentLayout = controller.value?.layout
+ if (!currentLayout)
+ return
+
+ layout.keyboardVisible = currentLayout.keyboardVisible
+ layout.visibleHeight = currentLayout.visibleHeight
+ layout.viewportBottom = currentLayout.viewportBottom
+ layout.viewportOffsetTop = currentLayout.viewportOffsetTop
+ })
+
+ watch([viewport, area, enabled], ([viewportElement, areaElement, keyboardEnabled], _, onCleanup) => {
+ if (!viewportElement || !areaElement || !keyboardEnabled) {
+ layout.keyboardVisible = false
+ layout.visibleHeight = layoutViewportHeight.value
+ layout.viewportBottom = layoutViewportHeight.value
+ layout.viewportOffsetTop = 0
+ return
+ }
+
+ const currentController = new AdaptiveInput({
+ area: areaElement,
+ viewport: viewportElement,
+ window: targetWindow,
+ })
+ controller.value = currentController
+ layout.keyboardVisible = currentController.layout.keyboardVisible
+ layout.visibleHeight = currentController.layout.visibleHeight
+ layout.viewportBottom = currentController.layout.viewportBottom
+ layout.viewportOffsetTop = currentController.layout.viewportOffsetTop
+
+ onCleanup(() => {
+ controller.value = undefined
+ currentController.dispose()
+ })
+ }, { flush: 'post', immediate: true })
+
+ watch(layoutViewportHeight, (height) => {
+ if (controller.value)
+ return
+
+ layout.keyboardVisible = false
+ layout.visibleHeight = height
+ layout.viewportBottom = height
+ layout.viewportOffsetTop = 0
+ }, { flush: 'sync' })
+
+ return toRefs(readonly(layout))
+}
diff --git a/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts b/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts
new file mode 100644
index 000000000..2df4302ac
--- /dev/null
+++ b/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts
@@ -0,0 +1,141 @@
+import type { MaybeRefOrGetter, Ref } from 'vue'
+
+import { defaultDocument, useElementSize, usePreferredReducedMotion, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
+import { computed, nextTick, onMounted, onScopeDispose, shallowRef, toValue, watch } from 'vue'
+
+import { useAdaptiveInput } from './use-adaptive-input'
+
+/** The AIRI elements and feature policy used to lay out the mobile chat surface. */
+export interface UseMobileInteractiveAreaLayoutOptions {
+ /** The region that contains the message composer and its related controls. */
+ area: Readonly[>
+ /** The scrollable controls beside the message composer. */
+ controlsIsland: Readonly][>
+ /** The content used to detect whether the controls island overflows. */
+ controlsIslandContent: Readonly][>
+ /**
+ * Enables keyboard-aware layout policy.
+ *
+ * @default true
+ */
+ enabled?: MaybeRefOrGetter
+ /** The message composer used to reserve controls-island space. */
+ messageComposer: Readonly][>
+ /** The mobile chat surface whose height follows the available viewport. */
+ viewport: Readonly][>
+}
+
+/**
+ * Applies AIRI's mobile chat proportions and transition policy to adaptive input values.
+ *
+ * This composable owns only AIRI presentation decisions. Browser keyboard measurement remains
+ * in {@link useAdaptiveInput}, so another framework or layout can consume the same geometry.
+ */
+export function useMobileInteractiveAreaLayout(options: UseMobileInteractiveAreaLayoutOptions) {
+ const enabled = computed(() => options.enabled === undefined || toValue(options.enabled))
+ const preferredMotion = usePreferredReducedMotion()
+ const controlsIslandNaturalHeight = shallowRef()
+ const controlsIslandOverflowing = shallowRef(false)
+ const { height: messageComposerHeight } = useElementSize(options.messageComposer, undefined, { box: 'border-box' })
+ const {
+ keyboardVisible,
+ visibleHeight,
+ viewportBottom,
+ viewportOffsetTop,
+ } = useAdaptiveInput({
+ area: options.area,
+ enabled,
+ viewport: options.viewport,
+ })
+
+ const screenSafeArea = useScreenSafeArea()
+ useResizeObserver(defaultDocument?.documentElement, () => screenSafeArea.update())
+ onMounted(() => screenSafeArea.update())
+
+ const viewportStyle = computed(() => enabled.value
+ ? { height: `${viewportBottom.value}px` }
+ : undefined)
+ const chatHistoryStyle = computed(() => enabled.value
+ ? { maxHeight: `${visibleHeight.value * 0.35}px` }
+ : undefined)
+ const controlsIslandMaxHeight = computed(() => {
+ const availableHeight = Math.max(visibleHeight.value - messageComposerHeight.value, 0)
+ if (!enabled.value || !keyboardVisible.value)
+ return availableHeight
+
+ return Math.min(availableHeight, visibleHeight.value * 0.45)
+ })
+ const controlsIslandHeight = computed(() => controlsIslandNaturalHeight.value === undefined
+ ? undefined
+ : Math.min(controlsIslandNaturalHeight.value, controlsIslandMaxHeight.value))
+ const controlsIslandStyle = computed(() => controlsIslandHeight.value === undefined
+ ? undefined
+ : { height: `${controlsIslandHeight.value}px` })
+ const messageComposerStyle = computed(() => ({
+ paddingBottom: `${enabled.value && keyboardVisible.value
+ ? 12
+ : Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 12)}px`,
+ }))
+
+ let areaAnimation: Animation | undefined
+ watch(keyboardVisible, async (_, __, onCleanup) => {
+ const target = options.area.value
+ if (!target || preferredMotion.value === 'reduce')
+ return
+
+ areaAnimation?.cancel()
+ const previousTop = target.getBoundingClientRect().top
+ let canceled = false
+ onCleanup(() => {
+ canceled = true
+ })
+
+ await nextTick()
+ if (canceled || target !== options.area.value)
+ return
+
+ const offset = previousTop - target.getBoundingClientRect().top
+ if (Math.abs(offset) < 1)
+ return
+
+ // WORKAROUND:
+ // NOTICE:
+ // Why: A CSS height transition leaves the input at its old position during Safari's focus check.
+ // Root cause: The keyboard workaround must apply the new layout before it calls focus().
+ // Code context: AdaptiveInput updates the viewport element synchronously before focus.
+ // Removal condition: Safari provides keyboard geometry before it applies the focus scroll.
+ areaAnimation = target.animate([
+ { transform: `translate3d(0, ${offset}px, 0)` },
+ { transform: 'translate3d(0, 0, 0)' },
+ ], {
+ duration: 250,
+ easing: 'cubic-bezier(0.32, 0.72, 0, 1)',
+ })
+ }, { flush: 'sync' })
+
+ function measureControlsIslandOverflow() {
+ const island = options.controlsIsland.value
+ if (!island) {
+ controlsIslandNaturalHeight.value = undefined
+ controlsIslandOverflowing.value = false
+ return
+ }
+
+ controlsIslandNaturalHeight.value = island.scrollHeight
+ controlsIslandOverflowing.value = island.scrollHeight > island.clientHeight + 1
+ }
+
+ useResizeObserver([options.controlsIsland, options.controlsIslandContent], measureControlsIslandOverflow)
+ watch(controlsIslandHeight, measureControlsIslandOverflow, { flush: 'post' })
+ onScopeDispose(() => areaAnimation?.cancel())
+
+ return {
+ chatHistoryStyle,
+ controlsIslandOverflowing,
+ controlsIslandStyle,
+ keyboardVisible,
+ messageComposerStyle,
+ viewportOffsetTop,
+ viewportStyle,
+ }
+}
]