feat(app): mobile improvements — native-feel viewport, keyboard, HIG tabs, PWA fix

- Lock viewport: position:fixed on html/body prevents iOS bounce scroll
- No zoom on input focus: maximum-scale=1, user-scalable=no
- Keyboard-responsive chat: visualViewport API detects keyboard, hides
  tab bar, scrolls chat to bottom, scrollIntoView on input focus
- iOS HIG tab bar: 49pt height, vertical icon+label, safe-area-inset-bottom
- PWA fix: manifest start_url/scope/id changed from '/' to './' for
  subpath deployment compatibility
- PlayerBar: variant prop (fixed/inline), inline on mobile above tab bar,
  fixed on desktop. No more overlap with tab bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-04 20:50:06 +00:00
co-authored by Claude Opus 4.6
parent 799ca131d9
commit a0a9cf8e54
9 changed files with 142 additions and 57 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="h-full overflow-hidden">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#0a0a0a" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#faf9f6" media="(prefers-color-scheme: light)" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
@@ -13,7 +13,7 @@
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<title>AIUI</title> <title>AIUI</title>
</head> </head>
<body class="antialiased"> <body class="antialiased h-full overflow-hidden fixed w-full">
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
+8 -2
View File
@@ -2,7 +2,7 @@
<div class="h-dvh flex flex-col" :class="currentTheme"> <div class="h-dvh flex flex-col" :class="currentTheme">
<RouterView /> <RouterView />
<ArticleOverlay /> <ArticleOverlay />
<PlayerBar /> <PlayerBar v-if="!isMobile" />
<PassphraseDialog <PassphraseDialog
:visible="showPassphrase" :visible="showPassphrase"
:is-creating="isCreatingPassphrase" :is-creating="isCreatingPassphrase"
@@ -13,7 +13,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { useArchy } from '@/composables/useArchy' import { useArchy } from '@/composables/useArchy'
@@ -30,6 +30,10 @@ import {
const { currentTheme, initTheme } = useTheme() const { currentTheme, initTheme } = useTheme()
const archy = useArchy() const archy = useArchy()
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
function onResize() { windowWidth.value = window.innerWidth }
const showPassphrase = ref(false) const showPassphrase = ref(false)
const isCreatingPassphrase = ref(false) const isCreatingPassphrase = ref(false)
@@ -54,6 +58,7 @@ async function handlePassphraseSubmit(passphrase: string) {
onMounted(() => { onMounted(() => {
initTheme() initTheme()
window.addEventListener('resize', onResize)
// Initialize Archy bridge when running embedded in Archipelago // Initialize Archy bridge when running embedded in Archipelago
archy.init() archy.init()
@@ -67,6 +72,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', onResize)
archy.destroy() archy.destroy()
}) })
</script> </script>
@@ -106,7 +106,7 @@
@keydown="handleKeydown" @keydown="handleKeydown"
@input="autoResize" @input="autoResize"
@paste="onPaste" @paste="onPaste"
@focus="focused = true" @focus="onInputFocus"
@blur="focused = false" @blur="focused = false"
/> />
<button <button
@@ -252,6 +252,13 @@ function closePalette() {
} }
} }
function onInputFocus() {
focused.value = true
nextTick(() => {
textareaRef.value?.scrollIntoView({ block: 'end', behavior: 'smooth' })
})
}
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (isPaletteMode.value && !paletteRef.value?.hasSelectedTemplate) { if (isPaletteMode.value && !paletteRef.value?.hasSelectedTemplate) {
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
@@ -179,6 +179,7 @@ const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel } = useAI() const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel } = useAI()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel() const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext' import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext() const codeContext = useCodeContext()
import { usePersonaStore } from '@/stores/personas' import { usePersonaStore } from '@/stores/personas'
const personaStore = usePersonaStore() const personaStore = usePersonaStore()
@@ -434,6 +435,12 @@ watch(chatCollapsed, (collapsed, wasCollapsed) => {
} }
}) })
// Scroll to bottom when mobile keyboard opens so latest messages + input stay visible
const { isKeyboardOpen } = useVisualViewport()
watch(isKeyboardOpen, (open) => {
if (open) scrollToBottom()
})
// Save/restore scroll position on conversation switch // Save/restore scroll position on conversation switch
watch( watch(
() => chatStore.activeConversationId, () => chatStore.activeConversationId,
@@ -2,7 +2,12 @@
<Transition name="player-slide"> <Transition name="player-slide">
<div <div
v-if="hasTrack" v-if="hasTrack"
class="fixed bottom-0 left-0 right-0 z-[999] flex items-center gap-4 px-4 py-3 path-glass-card rounded-none border-t-0 border-x-0 shadow-2xl" :class="[
props.variant === 'fixed'
? 'fixed bottom-0 left-0 right-0 z-[999]'
: 'w-full shrink-0',
'flex items-center gap-4 px-4 py-3 path-glass-card rounded-none border-t-0 border-x-0 shadow-2xl'
]"
> >
<!-- Plyr container: YouTube requires min 200x200px. Kept off-screen but sized. --> <!-- Plyr container: YouTube requires min 200x200px. Kept off-screen but sized. -->
<div <div
@@ -113,6 +118,10 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usePlayer } from '@/composables/usePlayer' import { usePlayer } from '@/composables/usePlayer'
import { fetchMusicCover } from '@/composables/useImageFallback' import { fetchMusicCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
variant?: 'fixed' | 'inline'
}>(), { variant: 'fixed' })
const { const {
currentSong, currentSong,
hasTrack, hasTrack,
@@ -0,0 +1,40 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* Tracks the visual viewport to detect mobile keyboard open/close.
* Uses the VisualViewport API to compute keyboard height as the difference
* between window.innerHeight and visualViewport.height.
*/
export function useVisualViewport() {
const keyboardHeight = ref(0)
const isKeyboardOpen = ref(false)
const viewportHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 0)
function onViewportChange() {
const vv = window.visualViewport
if (!vv) return
const kbHeight = Math.max(0, window.innerHeight - vv.height)
keyboardHeight.value = kbHeight
isKeyboardOpen.value = kbHeight > 100
viewportHeight.value = vv.height
}
onMounted(() => {
const vv = window.visualViewport
if (vv) {
vv.addEventListener('resize', onViewportChange)
vv.addEventListener('scroll', onViewportChange)
onViewportChange()
}
})
onUnmounted(() => {
const vv = window.visualViewport
if (vv) {
vv.removeEventListener('resize', onViewportChange)
vv.removeEventListener('scroll', onViewportChange)
}
})
return { keyboardHeight, isKeyboardOpen, viewportHeight }
}
+57 -47
View File
@@ -306,55 +306,62 @@
</div> </div>
</div> </div>
<!-- Bottom tab bar --> <!-- Inline player bar on mobile (above tab bar) -->
<PlayerBar variant="inline" />
<!-- Bottom tab bar (iOS HIG: 49pt + safe area) -->
<div <div
class="shrink-0 flex items-center px-2 py-1.5 gap-1" v-show="!isKeyboardOpen"
class="shrink-0"
:style="{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }"
> >
<button <div class="flex items-center h-[49px] px-2 gap-1">
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all duration-150" <button
:class="mobileTab === 'chat' class="flex-1 flex flex-col items-center justify-center h-[49px] min-h-[44px] rounded-xl text-[10px] font-medium tracking-wide transition-all duration-150 gap-0.5"
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900' :class="mobileTab === 'chat'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'" ? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
@click="mobileTab = 'chat'" : isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
> @click="mobileTab = 'chat'"
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
Chat </svg>
</button> <span>Chat</span>
<button </button>
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all duration-150 relative" <button
:class="mobileTab === 'content' class="flex-1 flex flex-col items-center justify-center h-[49px] min-h-[44px] rounded-xl text-[10px] font-medium tracking-wide transition-all duration-150 relative gap-0.5"
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900' :class="mobileTab === 'content'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'" ? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
@click="mobileTab = 'content'" : isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
> @click="mobileTab = 'content'"
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
Content </svg>
<span <span>Content</span>
v-if="panelOpen && mobileTab === 'chat'" <span
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent" v-if="panelOpen && mobileTab === 'chat'"
/> class="absolute top-1.5 right-[28%] w-1.5 h-1.5 rounded-full bg-accent"
</button> />
<button </button>
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all duration-150 relative" <button
:class="mobileTab === 'context' class="flex-1 flex flex-col items-center justify-center h-[49px] min-h-[44px] rounded-xl text-[10px] font-medium tracking-wide transition-all duration-150 relative gap-0.5"
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900' :class="mobileTab === 'context'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'" ? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
@click="mobileTab = 'context'" : isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
> @click="mobileTab = 'context'"
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
Context </svg>
<span <span>Context</span>
v-if="hasDetailOpen && mobileTab !== 'context'" <span
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent" v-if="hasDetailOpen && mobileTab !== 'context'"
/> class="absolute top-1.5 right-[28%] w-1.5 h-1.5 rounded-full bg-accent"
</button> />
</button>
</div>
</div> </div>
</div> </div>
@@ -367,12 +374,14 @@ import { useChatStore } from '@/stores/chat'
import { useTheme } from '@/composables/useTheme' import { useTheme } from '@/composables/useTheme'
import { useAI } from '@/composables/useAI' import { useAI } from '@/composables/useAI'
import { useContentPanel, type ContentTab } from '@/composables/useContentPanel' import { useContentPanel, type ContentTab } from '@/composables/useContentPanel'
import { useVisualViewport } from '@/composables/useVisualViewport'
import ChatWindow from '@/components/chat/ChatWindow.vue' import ChatWindow from '@/components/chat/ChatWindow.vue'
import ContentGridView from '@/components/content/ContentGridView.vue' import ContentGridView from '@/components/content/ContentGridView.vue'
import DetailView from '@/components/content/DetailView.vue' import DetailView from '@/components/content/DetailView.vue'
import CloseButton from '@/components/content/CloseButton.vue' import CloseButton from '@/components/content/CloseButton.vue'
import ContextLoader from '@/components/content/ContextLoader.vue' import ContextLoader from '@/components/content/ContextLoader.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue' import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import { useCodeContext } from '@/composables/useCodeContext' import { useCodeContext } from '@/composables/useCodeContext'
const chatStore = useChatStore() const chatStore = useChatStore()
@@ -446,6 +455,7 @@ const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024) const isMobile = computed(() => windowWidth.value < 1024)
const isWideDesktop = computed(() => windowWidth.value >= 1440) const isWideDesktop = computed(() => windowWidth.value >= 1440)
const mobileTab = ref<'chat' | 'content' | 'context'>('chat') const mobileTab = ref<'chat' | 'content' | 'context'>('chat')
const { isKeyboardOpen } = useVisualViewport()
function onResize() { windowWidth.value = window.innerWidth } function onResize() { windowWidth.value = window.innerWidth }
+6
View File
@@ -28,12 +28,18 @@
html { html {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
height: 100%;
overflow: hidden;
} }
body { body {
overscroll-behavior: none; overscroll-behavior: none;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
position: fixed;
width: 100%;
height: 100%;
overflow: hidden;
} }
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */ /* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
+3 -3
View File
@@ -25,7 +25,7 @@ export default defineConfig({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icon.svg', 'apple-touch-icon-180x180.png'], includeAssets: ['favicon.svg', 'icon.svg', 'apple-touch-icon-180x180.png'],
manifest: { manifest: {
id: '/', id: './',
name: 'AIUI', name: 'AIUI',
short_name: 'AIUI', short_name: 'AIUI',
description: 'AI chat interface with rich content surfaces', description: 'AI chat interface with rich content surfaces',
@@ -34,8 +34,8 @@ export default defineConfig({
display: 'standalone', display: 'standalone',
display_override: ['standalone', 'minimal-ui'], display_override: ['standalone', 'minimal-ui'],
orientation: 'any', orientation: 'any',
start_url: '/', start_url: './',
scope: '/', scope: './',
icons: [ icons: [
{ {
src: 'pwa-192x192.png', src: 'pwa-192x192.png',