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:
co-authored by
Claude Opus 4.6
parent
799ca131d9
commit
a0a9cf8e54
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 ===== */
|
||||
|
||||
Reference in New Issue
Block a user