diff --git a/.tool-versions b/.tool-versions
index a996bb002..39c4ea76b 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1 +1,2 @@
nodejs 26.7.0
+pnpm 11.24.0
diff --git a/apps/stage-pocket/src/pages/index.vue b/apps/stage-pocket/src/pages/index.vue
index eb2a621a3..88482db1a 100644
--- a/apps/stage-pocket/src/pages/index.vue
+++ b/apps/stage-pocket/src/pages/index.vue
@@ -18,7 +18,7 @@ import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/module
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'
import WebSocketStatusButton from '../components/websocket-status-button.vue'
@@ -31,6 +31,16 @@ function handleSettingsOpen(open: boolean) {
const positionCursor = useMouse()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
+const stageViewport = shallowRef({ height: 0, offsetTop: 0 })
+const stageSurfaceStyle = computed(() => isMobile.value
+ ? {
+ position: 'fixed' as const,
+ inset: '0',
+ height: stageViewport.value.height > 0 ? `${stageViewport.value.height}px` : '100dvh',
+ transform: `translate3d(0, ${stageViewport.value.offsetTop}px, 0)`,
+ willChange: 'transform',
+ }
+ : undefined)
const backgroundStore = useBackgroundStore()
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
@@ -178,9 +188,15 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
ref="backgroundSurface"
class="widgets top-widgets"
:background="selectedOption"
+ :style="stageSurfaceStyle"
:top-color="sampledColor"
>
-
+
@@ -208,13 +224,19 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
/>
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/apps/stage-web/index.html b/apps/stage-web/index.html
index d99cb5fbe..5f6ea14de 100644
--- a/apps/stage-web/index.html
+++ b/apps/stage-web/index.html
@@ -3,7 +3,7 @@
AIRI
-
+
diff --git a/apps/stage-web/src/pages/index.vue b/apps/stage-web/src/pages/index.vue
index 3889b6262..ca1490f00 100644
--- a/apps/stage-web/src/pages/index.vue
+++ b/apps/stage-web/src/pages/index.vue
@@ -28,20 +28,18 @@ function handleSettingsOpen(open: boolean) {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
-const stageViewportOffset = shallowRef(0)
-// WORKAROUND:
+const stageViewport = shallowRef({ height: 0, offsetTop: 0 })
// 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)`,
+ height: stageViewport.value.height > 0 ? `${stageViewport.value.height}px` : '100dvh',
+ transform: `translate3d(0, ${stageViewport.value.offsetTop}px, 0)`,
willChange: 'transform',
}
: undefined)
@@ -205,7 +203,7 @@ const cursorPosition = computed(() => ({
@@ -239,9 +237,8 @@ const cursorPosition = computed(() => ({
diff --git a/packages/stage-layouts/src/browser/adaptive-input.ts b/packages/stage-layouts/src/browser/adaptive-input.ts
index b5b7c2529..3f3cecbed 100644
--- a/packages/stage-layouts/src/browser/adaptive-input.ts
+++ b/packages/stage-layouts/src/browser/adaptive-input.ts
@@ -19,6 +19,8 @@ export interface AdaptiveInputLayout {
* If false, the input region can use the normal viewport layout.
*/
keyboardVisible: boolean
+ /** The layout viewport height before the software keyboard changes the visible area. */
+ stableViewportHeight: number
/** 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. */
@@ -31,6 +33,12 @@ export interface AdaptiveInputLayout {
export interface AdaptiveInputOptions {
/** The region that contains editable controls and moves above the keyboard. */
area: HTMLElement
+ /**
+ * Lets the virtual keyboard cover page content so this controller can position the input area.
+ *
+ * @default true
+ */
+ overlayVirtualKeyboard?: boolean
/** The region whose height follows the available viewport. */
viewport: HTMLElement
/**
@@ -102,7 +110,7 @@ export class AdaptiveInput extends EventTarget {
private synchronousHeightValue: string | undefined
private viewportSample: ViewportSample | undefined
- private readonly overlaysContentBeforeStart: boolean | undefined
+ private readonly overlaysContentBeforeStart?: boolean
constructor(options: AdaptiveInputOptions) {
super()
@@ -117,6 +125,7 @@ export class AdaptiveInput extends EventTarget {
this.referenceLayoutHeight = initialHeight
this.layoutValue = {
keyboardVisible: false,
+ stableViewportHeight: initialHeight,
visibleHeight: initialHeight,
viewportBottom: initialHeight,
viewportOffsetTop: 0,
@@ -124,9 +133,10 @@ export class AdaptiveInput extends EventTarget {
// 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)
+ if (this.virtualKeyboard && options.overlayVirtualKeyboard !== false) {
+ this.overlaysContentBeforeStart = this.virtualKeyboard.overlaysContent
this.virtualKeyboard.overlaysContent = true
+ }
const activeElement = targetWindow.document.activeElement
this.focusPhase = activeElement !== null
@@ -287,6 +297,7 @@ export class AdaptiveInput extends EventTarget {
this.layoutValue = {
keyboardVisible,
+ stableViewportHeight: stableLayoutHeight,
visibleHeight,
viewportBottom,
viewportOffsetTop,
@@ -334,6 +345,7 @@ export class AdaptiveInput extends EventTarget {
const normalHeight = this.referenceLayoutHeight || readViewportProfile(this.targetWindow).height
this.layoutValue = {
keyboardVisible: false,
+ stableViewportHeight: normalHeight,
visibleHeight: normalHeight,
viewportBottom: normalHeight,
viewportOffsetTop: this.visualViewport?.offsetTop ?? 0,
@@ -403,6 +415,7 @@ export class AdaptiveInput extends EventTarget {
this.predictedViewportHeight = cachedHeight
this.layoutValue = {
keyboardVisible: true,
+ stableViewportHeight: currentProfile.height,
visibleHeight: cachedHeight,
viewportBottom: cachedHeight,
viewportOffsetTop: 0,
@@ -438,6 +451,7 @@ export class AdaptiveInput extends EventTarget {
this.predictedViewportHeight = undefined
this.layoutValue = {
keyboardVisible: false,
+ stableViewportHeight: currentProfile.height,
visibleHeight: currentProfile.height,
viewportBottom: currentProfile.height,
viewportOffsetTop: 0,
diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
index 0eeb4f749..6eee3cf06 100644
--- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
+++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue
@@ -33,21 +33,9 @@ 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]
+ /** Reports the stable height and offset that keep the Stage in the same screen position. */
+ stageViewportChange: [viewport: { height: number, offsetTop: number }]
}>()
const { isDark, toggleDark } = useTheme()
@@ -112,29 +100,31 @@ const {
controlsIslandOverflowing,
controlsIslandStyle,
messageComposerStyle,
+ stableViewportHeight,
viewportOffsetTop,
viewportStyle: mobileInteractiveAreaStyle,
} = useMobileInteractiveAreaLayout({
area: interactionControls,
controlsIsland,
controlsIslandContent,
- enabled: () => props.keyboardAvoidance,
messageComposer,
viewport: mobileInteractiveArea,
})
-watch(viewportOffsetTop, offsetTop => emit('viewportOffsetChange', offsetTop), { immediate: true })
+watch(
+ [stableViewportHeight, viewportOffsetTop],
+ ([height, offsetTop]) => emit('stageViewportChange', { height, offsetTop }),
+ { immediate: true },
+)
-const mobileInteractiveAreaClass = computed(() => [
- 'pointer-events-none fixed inset-x-0 z-20 w-full',
+const mobileInteractiveAreaClass = [
+ 'pointer-events-none fixed inset-x-0 top-0 z-20 w-full',
'flex flex-col',
- props.keyboardAvoidance ? 'top-0' : 'bottom-0',
-])
-const chatHistoryClass = computed(() => [
+]
+const chatHistoryClass = [
'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(() => [
'absolute right-0 translate-y-[-100%]',
'max-w-full overflow-y-auto overscroll-contain px-3 py-3 font-sans scrollbar-none',
diff --git a/packages/stage-layouts/src/composables/use-adaptive-input.ts b/packages/stage-layouts/src/composables/use-adaptive-input.ts
index 72069711f..fe8df9bbe 100644
--- a/packages/stage-layouts/src/composables/use-adaptive-input.ts
+++ b/packages/stage-layouts/src/composables/use-adaptive-input.ts
@@ -16,6 +16,12 @@ export interface UseAdaptiveInputOptions extends ConfigurableWindow {
* @default true
*/
enabled?: MaybeRefOrGetter
+ /**
+ * Lets the virtual keyboard cover page content so the controller can position the input area.
+ *
+ * @default true
+ */
+ overlayVirtualKeyboard?: boolean
/** The region that contains editable controls and moves above the keyboard. */
area: MaybeComputedElementRef
/** The region whose height follows the available viewport. */
@@ -41,41 +47,44 @@ export function useAdaptiveInput(options: UseAdaptiveInputOptions) {
})
const layout = shallowReactive({
keyboardVisible: false,
+ stableViewportHeight: layoutViewportHeight.value,
visibleHeight: layoutViewportHeight.value,
viewportBottom: layoutViewportHeight.value,
viewportOffsetTop: 0,
})
+ function resetLayout(height: number) {
+ Object.assign(layout, {
+ keyboardVisible: false,
+ stableViewportHeight: height,
+ visibleHeight: height,
+ viewportBottom: height,
+ 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
+ Object.assign(layout, currentLayout)
})
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
+ resetLayout(layoutViewportHeight.value)
return
}
const currentController = new AdaptiveInput({
area: areaElement,
+ overlayVirtualKeyboard: options.overlayVirtualKeyboard,
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
+ Object.assign(layout, currentController.layout)
onCleanup(() => {
controller.value = undefined
@@ -87,10 +96,7 @@ export function useAdaptiveInput(options: UseAdaptiveInputOptions) {
if (controller.value)
return
- layout.keyboardVisible = false
- layout.visibleHeight = height
- layout.viewportBottom = height
- layout.viewportOffsetTop = 0
+ resetLayout(height)
}, { 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
index 2df4302ac..ecb4cbbcd 100644
--- a/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts
+++ b/packages/stage-layouts/src/composables/use-mobile-interactive-area-layout.ts
@@ -39,12 +39,14 @@ export function useMobileInteractiveAreaLayout(options: UseMobileInteractiveArea
const { height: messageComposerHeight } = useElementSize(options.messageComposer, undefined, { box: 'border-box' })
const {
keyboardVisible,
+ stableViewportHeight,
visibleHeight,
viewportBottom,
viewportOffsetTop,
} = useAdaptiveInput({
area: options.area,
enabled,
+ overlayVirtualKeyboard: false,
viewport: options.viewport,
})
@@ -135,6 +137,7 @@ export function useMobileInteractiveAreaLayout(options: UseMobileInteractiveArea
controlsIslandStyle,
keyboardVisible,
messageComposerStyle,
+ stableViewportHeight,
viewportOffsetTop,
viewportStyle,
}
diff --git a/packages/stage-ui-live2d/src/components/scenes/Live2D.vue b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue
index dac334208..ba64d0a80 100644
--- a/packages/stage-ui-live2d/src/components/scenes/Live2D.vue
+++ b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue
@@ -106,7 +106,6 @@ defineExpose({
:height="height"
:resolution="live2dRenderScale"
:max-fps="live2dMaxFps"
- max-h="100dvh"
@error="emit('error', $event)"
>
{
-
+
diff --git a/packages/stage-ui-spine/src/components/scenes/Spine.vue b/packages/stage-ui-spine/src/components/scenes/Spine.vue
index b7173876b..c8d9c81e3 100644
--- a/packages/stage-ui-spine/src/components/scenes/Spine.vue
+++ b/packages/stage-ui-spine/src/components/scenes/Spine.vue
@@ -56,7 +56,6 @@ defineExpose({
:width="width"
:height="height"
:resolution="renderScale"
- max-h="100dvh"
>
{
-