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>
<html lang="en">
<html lang="en" class="h-full overflow-hidden">
<head>
<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="#faf9f6" media="(prefers-color-scheme: light)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
@@ -13,7 +13,7 @@
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<title>AIUI</title>
</head>
<body class="antialiased">
<body class="antialiased h-full overflow-hidden fixed w-full">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
+8 -2
View File
@@ -2,7 +2,7 @@
<div class="h-dvh flex flex-col" :class="currentTheme">
<RouterView />
<ArticleOverlay />
<PlayerBar />
<PlayerBar v-if="!isMobile" />
<PassphraseDialog
:visible="showPassphrase"
:is-creating="isCreatingPassphrase"
@@ -13,7 +13,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterView } from 'vue-router'
import { useTheme } from '@/composables/useTheme'
import { useArchy } from '@/composables/useArchy'
@@ -30,6 +30,10 @@ import {
const { currentTheme, initTheme } = useTheme()
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 isCreatingPassphrase = ref(false)
@@ -54,6 +58,7 @@ async function handlePassphraseSubmit(passphrase: string) {
onMounted(() => {
initTheme()
window.addEventListener('resize', onResize)
// Initialize Archy bridge when running embedded in Archipelago
archy.init()
@@ -67,6 +72,7 @@ onMounted(() => {
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
archy.destroy()
})
</script>
@@ -106,7 +106,7 @@
@keydown="handleKeydown"
@input="autoResize"
@paste="onPaste"
@focus="focused = true"
@focus="onInputFocus"
@blur="focused = false"
/>
<button
@@ -252,6 +252,13 @@ function closePalette() {
}
}
function onInputFocus() {
focused.value = true
nextTick(() => {
textareaRef.value?.scrollIntoView({ block: 'end', behavior: 'smooth' })
})
}
function handleKeydown(e: KeyboardEvent) {
if (isPaletteMode.value && !paletteRef.value?.hasSelectedTemplate) {
if (e.key === 'ArrowUp') {
@@ -179,6 +179,7 @@ const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel } = useAI()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext()
import { usePersonaStore } from '@/stores/personas'
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
watch(
() => chatStore.activeConversationId,
@@ -2,7 +2,12 @@
<Transition name="player-slide">
<div
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. -->
<div
@@ -113,6 +118,10 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usePlayer } from '@/composables/usePlayer'
import { fetchMusicCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
variant?: 'fixed' | 'inline'
}>(), { variant: 'fixed' })
const {
currentSong,
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>
<!-- Bottom tab bar -->
<!-- Inline player bar on mobile (above tab bar) -->
<PlayerBar variant="inline" />
<!-- Bottom tab bar (iOS HIG: 49pt + safe area) -->
<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
class="flex-1 flex items-center justify-center gap-1.5 py-2 rounded-xl text-xs font-medium transition-all duration-150"
:class="mobileTab === 'chat'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: 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>
Chat
</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"
:class="mobileTab === 'content'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: 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>
Content
<span
v-if="panelOpen && mobileTab === 'chat'"
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</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"
:class="mobileTab === 'context'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: 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" />
<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" />
</svg>
Context
<span
v-if="hasDetailOpen && mobileTab !== 'context'"
class="absolute top-1 right-[30%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</button>
<div class="flex items-center h-[49px] px-2 gap-1">
<button
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"
:class="mobileTab === 'chat'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="mobileTab = 'chat'"
>
<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="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>
<span>Chat</span>
</button>
<button
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"
:class="mobileTab === 'content'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="mobileTab = 'content'"
>
<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="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>
<span>Content</span>
<span
v-if="panelOpen && mobileTab === 'chat'"
class="absolute top-1.5 right-[28%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</button>
<button
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"
:class="mobileTab === 'context'
? isDark ? 'bg-white/10 text-white/90' : 'bg-black/8 text-gray-900'
: isDark ? 'text-white/40 hover:text-white/60' : 'text-gray-400 hover:text-gray-600'"
@click="mobileTab = 'context'"
>
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<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" />
</svg>
<span>Context</span>
<span
v-if="hasDetailOpen && mobileTab !== 'context'"
class="absolute top-1.5 right-[28%] w-1.5 h-1.5 rounded-full bg-accent"
/>
</button>
</div>
</div>
</div>
@@ -367,12 +374,14 @@ import { useChatStore } from '@/stores/chat'
import { useTheme } from '@/composables/useTheme'
import { useAI } from '@/composables/useAI'
import { useContentPanel, type ContentTab } from '@/composables/useContentPanel'
import { useVisualViewport } from '@/composables/useVisualViewport'
import ChatWindow from '@/components/chat/ChatWindow.vue'
import ContentGridView from '@/components/content/ContentGridView.vue'
import DetailView from '@/components/content/DetailView.vue'
import CloseButton from '@/components/content/CloseButton.vue'
import ContextLoader from '@/components/content/ContextLoader.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import PlayerBar from '@/components/player/PlayerBar.vue'
import { useCodeContext } from '@/composables/useCodeContext'
const chatStore = useChatStore()
@@ -446,6 +455,7 @@ const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const isWideDesktop = computed(() => windowWidth.value >= 1440)
const mobileTab = ref<'chat' | 'content' | 'context'>('chat')
const { isKeyboardOpen } = useVisualViewport()
function onResize() { windowWidth.value = window.innerWidth }
+6
View File
@@ -28,12 +28,18 @@
html {
-webkit-tap-highlight-color: transparent;
height: 100%;
overflow: hidden;
}
body {
overscroll-behavior: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
position: fixed;
width: 100%;
height: 100%;
overflow: hidden;
}
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
+3 -3
View File
@@ -25,7 +25,7 @@ export default defineConfig({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icon.svg', 'apple-touch-icon-180x180.png'],
manifest: {
id: '/',
id: './',
name: 'AIUI',
short_name: 'AIUI',
description: 'AI chat interface with rich content surfaces',
@@ -34,8 +34,8 @@ export default defineConfig({
display: 'standalone',
display_override: ['standalone', 'minimal-ui'],
orientation: 'any',
start_url: '/',
scope: '/',
start_url: './',
scope: './',
icons: [
{
src: 'pwa-192x192.png',