- Mobile launches use the store-driven panel (no route push) so the background tab no longer changes and closing returns to where you launched from. - Tab-only apps open directly (in-app WebView on companion / new tab on PWA) — no "this app opens in a tab" interstitial. - Shared AppLoadingScreen (app icon + progress bar) on the app session and the legacy iframe overlay instead of a black screen. - Pin the dashboard to 100dvh on mobile so the mesh chat/tools panes stop sliding under the bottom tab bar in mobile browsers (no-op in the companion WebView). - ElectrumX/electrs/electrs-ui ids now resolve to the real ElectrumX icon in My Apps. - isMobile made reactive so overlay/footer/teleport decisions track the viewport. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
670 lines
20 KiB
Vue
670 lines
20 KiB
Vue
<template>
|
|
<div class="app-session-root">
|
|
<Teleport to="body" :disabled="isInlinePanel && !isMobile">
|
|
<div
|
|
:class="backdropClasses"
|
|
@click.self="handleBackdropClick"
|
|
>
|
|
<div
|
|
ref="sessionRef"
|
|
:class="panelClasses"
|
|
@click.stop
|
|
>
|
|
<AppSessionHeader
|
|
:app-title="appTitle"
|
|
:is-refreshing="isRefreshing"
|
|
:display-mode="displayMode"
|
|
@go-back="iframeGoBack"
|
|
@go-forward="iframeGoForward"
|
|
@refresh="refresh"
|
|
@open-new-tab="openNewTab"
|
|
@close="closeSession"
|
|
@set-mode="setMode"
|
|
/>
|
|
|
|
<AppSessionFrame
|
|
ref="frameRef"
|
|
:app-url="appUrl"
|
|
:app-id="appId"
|
|
:app-title="appTitle"
|
|
:app-icon="appIcon"
|
|
:loading="loading"
|
|
:iframe-blocked="iframeBlocked"
|
|
:must-open-new-tab="mustOpenNewTab"
|
|
:auto-retry-count="autoRetryCount"
|
|
:refresh-key="refreshKey"
|
|
:blocked-reason="blockedReason"
|
|
:blocked-title="blockedTitle"
|
|
:electrs-sync="electrsSync"
|
|
@iframe-load="onLoad"
|
|
@iframe-error="onError"
|
|
@refresh="refresh"
|
|
@open-new-tab-and-back="openNewTabAndBack"
|
|
/>
|
|
|
|
<!-- Mobile: gamepad for botfights (with utility buttons), browser bar for everything else -->
|
|
<MobileGamepad
|
|
v-if="isMobile && appId === 'botfights'"
|
|
:iframe-ref="iframeRef ?? null"
|
|
:player="1"
|
|
@refresh="refresh"
|
|
@openBrowser="openNewTab"
|
|
@close="closeSession"
|
|
/>
|
|
<div v-else class="md:hidden app-session-mobile-bar">
|
|
<button class="app-session-bar-btn" aria-label="Back" @click="iframeGoBack">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<button class="app-session-bar-btn" aria-label="Forward" @click="iframeGoForward">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
<button class="app-session-bar-btn" aria-label="Refresh" @click="refresh">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" :class="{ 'animate-spin': isRefreshing }">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.64 15.36A8 8 0 0018.36 18M18.36 8.64A8 8 0 005.64 6" />
|
|
</svg>
|
|
</button>
|
|
<button class="app-session-bar-btn" aria-label="Open in new tab" @click="openNewTab">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
</svg>
|
|
</button>
|
|
<button class="app-session-bar-btn" aria-label="Close" @click="closeSession">
|
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<NostrIdentityPicker
|
|
:show="showIdentityPicker"
|
|
:app-name="appTitle"
|
|
@select="identity.onIdentitySelected"
|
|
@cancel="showIdentityPicker = false"
|
|
/>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
|
import { useAppStore } from '@/stores/app'
|
|
import { useScreensaverStore } from '@/stores/screensaver'
|
|
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
|
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
|
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
|
import MobileGamepad from './appSession/MobileGamepad.vue'
|
|
import {
|
|
type DisplayMode, DISPLAY_MODE_KEY, NEW_TAB_APPS, IFRAME_BLOCKED_APPS,
|
|
resolveAppUrl, resolveAppTitle,
|
|
} from './appSession/appSessionConfig'
|
|
import { launchBlockedReason, resolveAppIcon } from './apps/appsConfig'
|
|
import { useAppIdentity } from './appSession/useAppIdentity'
|
|
import { useNostrBridge } from './appSession/useNostrBridge'
|
|
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
|
import { useElectrsSync } from '@/composables/useElectrsSync'
|
|
|
|
const props = defineProps<{
|
|
appIdProp?: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
}>()
|
|
|
|
/** True when rendered inline via store (panel mode), false when route-based */
|
|
const isInlinePanel = computed(() => !!props.appIdProp)
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const store = useAppStore()
|
|
const screensaverStore = useScreensaverStore()
|
|
|
|
const sessionRef = ref<HTMLElement | null>(null)
|
|
const frameRef = ref<InstanceType<typeof AppSessionFrame> | null>(null)
|
|
const loading = ref(true)
|
|
const isRefreshing = ref(false)
|
|
const iframeBlocked = ref(false)
|
|
const refreshKey = ref(0)
|
|
const showIdentityPicker = ref(false)
|
|
const autoRetryCount = ref(0)
|
|
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
|
let autoRetryId: ReturnType<typeof setTimeout> | null = null
|
|
let iframeCheckId: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// Display mode -- persisted in localStorage
|
|
const displayMode = ref<DisplayMode>(
|
|
(localStorage.getItem(DISPLAY_MODE_KEY) as DisplayMode) || 'panel'
|
|
)
|
|
|
|
const appId = computed(() => {
|
|
const id = props.appIdProp || (route.params.appId as string)
|
|
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
|
|
router.replace('/dashboard/apps')
|
|
return ''
|
|
}
|
|
return id
|
|
})
|
|
|
|
const appTitle = computed(() => resolveAppTitle(appId.value))
|
|
const packageEntry = computed(() => store.data?.['package-data']?.[appId.value] || null)
|
|
const appIcon = computed(() =>
|
|
packageEntry.value
|
|
? resolveAppIcon(appId.value, packageEntry.value)
|
|
: `/assets/img/app-icons/${appId.value}.png`
|
|
)
|
|
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
|
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
|
// Reactive so the overlay/teleport/footer/animation decisions track the live
|
|
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
|
|
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
|
|
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
|
|
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
|
|
|
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
|
// serves clients once its index is built). Poll /electrs-status while this is
|
|
// the Electrum app; pass the status to the frame only while still syncing.
|
|
const isElectrsApp = computed(() =>
|
|
['electrumx', 'electrs-ui', 'archy-electrs-ui'].includes(appId.value)
|
|
)
|
|
const { status: electrsStatus, syncing: electrsSyncing, start: startElectrsPoll, stop: stopElectrsPoll } =
|
|
useElectrsSync()
|
|
const electrsSync = computed(() =>
|
|
isElectrsApp.value && electrsSyncing.value ? electrsStatus.value : null
|
|
)
|
|
watch(
|
|
isElectrsApp,
|
|
(on) => { if (on) startElectrsPoll(); else stopElectrsPoll() },
|
|
{ immediate: true }
|
|
)
|
|
const screensaverReason = computed(() => `app-session:${appId.value}`)
|
|
const screensaverSuppressedApps = new Set([
|
|
'indeedhub',
|
|
'jellyfin',
|
|
'immich',
|
|
'photoprism',
|
|
'filebrowser',
|
|
])
|
|
|
|
const appUrl = computed(() => {
|
|
const runtimeUrl = store.data?.['package-data']?.[appId.value]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
|
return resolveAppUrl(appId.value, route.query.path as string | undefined, runtimeUrl)
|
|
})
|
|
|
|
function closeRouteSession() {
|
|
const fallback = route.query.returnTo
|
|
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
|
? fallback
|
|
: '/dashboard/apps'
|
|
router.replace(fallbackPath).catch(() => {})
|
|
}
|
|
|
|
// --- Identity & Nostr bridge ---
|
|
|
|
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
|
|
|
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
|
|
const nostrBridge = useNostrBridge(identity.getStoredIdentity, () => appUrl.value)
|
|
|
|
// --- Display mode ---
|
|
|
|
function setMode(mode: DisplayMode) {
|
|
if (displayMode.value === 'fullscreen' && document.fullscreenElement) {
|
|
document.exitFullscreen().catch(() => {})
|
|
}
|
|
displayMode.value = mode
|
|
localStorage.setItem(DISPLAY_MODE_KEY, mode)
|
|
|
|
// Switch from inline panel to route-based overlay/fullscreen
|
|
if (isInlinePanel.value && mode !== 'panel') {
|
|
const id = appId.value
|
|
emit('close')
|
|
const returnTo = route.fullPath.startsWith('/dashboard') ? route.fullPath : '/dashboard/apps'
|
|
router.push({ name: 'app-session', params: { appId: id }, query: { returnTo } })
|
|
return
|
|
}
|
|
|
|
// Switch from route-based to inline panel
|
|
if (!isInlinePanel.value && mode === 'panel') {
|
|
const id = appId.value
|
|
const launcher = useAppLauncherStore()
|
|
const fallback = route.query.returnTo
|
|
const fallbackPath = typeof fallback === 'string' && fallback.startsWith('/dashboard')
|
|
? fallback
|
|
: '/dashboard/apps'
|
|
router.push(fallbackPath).then(() => {
|
|
launcher.panelAppId = id
|
|
})
|
|
return
|
|
}
|
|
|
|
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
|
sessionRef.value.requestFullscreen().catch(() => {})
|
|
}
|
|
}
|
|
|
|
// Reactive classes based on display mode. On mobile the store-driven panel
|
|
// renders as a full-screen overlay (teleported to body) so it covers the nav
|
|
// and the underlying page never changes — desktop keeps the inline panel.
|
|
const backdropClasses = computed(() => {
|
|
if (isInlinePanel.value && !isMobile.value) return 'app-session-backdrop-inline'
|
|
return 'app-session-backdrop-overlay'
|
|
})
|
|
|
|
const panelClasses = computed(() => {
|
|
const base = 'app-session-panel glass-card'
|
|
if (isInlinePanel.value && !isMobile.value) return `${base} app-session-inline`
|
|
if (displayMode.value === 'fullscreen' && !isMobile.value) return `${base} app-session-fullscreen`
|
|
return `${base} app-session-overlay`
|
|
})
|
|
|
|
// --- Lifecycle handlers ---
|
|
|
|
function onLoad() {
|
|
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
|
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
|
loading.value = false
|
|
isRefreshing.value = false
|
|
autoRetryCount.value = 0
|
|
// Check if iframe actually loaded content (same-origin only)
|
|
iframeCheckId = setTimeout(() => {
|
|
try {
|
|
const iframe = frameRef.value?.iframeRef
|
|
const doc = iframe?.contentDocument
|
|
if (doc) {
|
|
const body = doc.body
|
|
if (!body || (body.children.length === 0 && body.innerText.trim() === '')) {
|
|
iframeBlocked.value = true
|
|
}
|
|
}
|
|
} catch {
|
|
// Cross-origin -- can't check, assume OK
|
|
}
|
|
}, 1000)
|
|
identity.onIframeLoadIdentity()
|
|
}
|
|
|
|
function onError() {
|
|
if (loadTimeoutId) { clearTimeout(loadTimeoutId); loadTimeoutId = null }
|
|
loading.value = false
|
|
isRefreshing.value = false
|
|
iframeBlocked.value = true
|
|
// Auto-retry up to 6 times (60s total) for apps that are still starting
|
|
if (!mustOpenNewTab.value && autoRetryCount.value < 6) {
|
|
autoRetryId = setTimeout(() => {
|
|
autoRetryCount.value++
|
|
refresh()
|
|
}, 10000)
|
|
}
|
|
}
|
|
|
|
function refresh() {
|
|
if (autoRetryId) { clearTimeout(autoRetryId); autoRetryId = null }
|
|
isRefreshing.value = true
|
|
loading.value = true
|
|
iframeBlocked.value = false
|
|
refreshKey.value++
|
|
startLoadTimeout()
|
|
}
|
|
|
|
function startLoadTimeout() {
|
|
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
|
loadTimeoutId = setTimeout(() => {
|
|
if (loading.value) {
|
|
loading.value = false
|
|
iframeBlocked.value = true
|
|
}
|
|
}, 12000)
|
|
}
|
|
|
|
function openNewTabAndBack() {
|
|
if (appUrl.value) openExternalUrl(appUrl.value)
|
|
closeSession()
|
|
}
|
|
|
|
function openNewTab() {
|
|
if (appUrl.value) openExternalUrl(appUrl.value)
|
|
}
|
|
|
|
function iframeGoBack() {
|
|
try { frameRef.value?.iframeRef?.contentWindow?.history.back() } catch {}
|
|
}
|
|
|
|
function iframeGoForward() {
|
|
try { frameRef.value?.iframeRef?.contentWindow?.history.forward() } catch {}
|
|
}
|
|
|
|
function handleBackdropClick() {
|
|
closeSession()
|
|
}
|
|
|
|
function closeSession() {
|
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
|
if (isInlinePanel.value) emit('close')
|
|
else closeRouteSession()
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
|
else closeSession()
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
|
|
function onFullscreenChange() {
|
|
if (!document.fullscreenElement && displayMode.value === 'fullscreen') {
|
|
displayMode.value = 'overlay'
|
|
localStorage.setItem(DISPLAY_MODE_KEY, 'overlay')
|
|
}
|
|
}
|
|
|
|
function onMessage(e: MessageEvent) {
|
|
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
|
|
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest()
|
|
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
|
|
if (e.data?.type === 'archipelago:media:idle') screensaverStore.resume(screensaverReason.value)
|
|
}
|
|
|
|
// Enter fullscreen on mount if mode is fullscreen
|
|
watch(displayMode, (mode) => {
|
|
if (mode === 'fullscreen' && sessionRef.value && !document.fullscreenElement) {
|
|
sessionRef.value.requestFullscreen().catch(() => {})
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
// Apps that block iframes (X-Frame-Options) can't be shown in the session.
|
|
// Open them directly instead of showing a "this app opens in a tab"
|
|
// interstitial: desktop → new browser tab; mobile → in-app WebView (companion)
|
|
// or new tab (PWA). Then dismiss the (empty) session surface.
|
|
if (mustOpenNewTab.value && appUrl.value) {
|
|
if (isMobile.value) openInAppOrNewTab(appUrl.value)
|
|
else window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
|
if (isInlinePanel.value) emit('close')
|
|
else closeRouteSession()
|
|
return
|
|
}
|
|
|
|
window.addEventListener('keydown', onKeyDown, true)
|
|
window.addEventListener('message', onMessage)
|
|
window.addEventListener('resize', updateIsMobile)
|
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
|
if (IFRAME_BLOCKED_APPS.has(appId.value)) {
|
|
loading.value = false
|
|
iframeBlocked.value = true
|
|
} else {
|
|
startLoadTimeout()
|
|
}
|
|
if (displayMode.value === 'fullscreen') {
|
|
requestAnimationFrame(() => {
|
|
sessionRef.value?.requestFullscreen().catch(() => {})
|
|
})
|
|
}
|
|
if (screensaverSuppressedApps.has(appId.value)) {
|
|
screensaverStore.suppress(screensaverReason.value)
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
|
if (autoRetryId) clearTimeout(autoRetryId)
|
|
if (iframeCheckId) clearTimeout(iframeCheckId)
|
|
window.removeEventListener('keydown', onKeyDown, true)
|
|
window.removeEventListener('message', onMessage)
|
|
window.removeEventListener('resize', updateIsMobile)
|
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
|
screensaverStore.resume(screensaverReason.value)
|
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
/* Unscoped so children can use these classes */
|
|
.app-session-root {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
/* Inline panel mode -- fills content area, no blur, original layout */
|
|
.app-session-backdrop-inline {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
padding: 0;
|
|
}
|
|
|
|
.app-session-inline {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 0;
|
|
border: none;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.app-session-backdrop-inline {
|
|
padding: 1.5rem;
|
|
}
|
|
.app-session-inline {
|
|
border-radius: 1rem;
|
|
max-width: calc(100% - 1rem);
|
|
max-height: calc(100vh - 6rem);
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
}
|
|
}
|
|
|
|
/* Overlay mode -- covers entire viewport including sidebar */
|
|
.app-session-backdrop-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 2400;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(12px);
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.app-session-backdrop-overlay {
|
|
padding: 2.5rem;
|
|
}
|
|
}
|
|
|
|
.app-session-overlay {
|
|
position: relative;
|
|
z-index: 10;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 0;
|
|
border: none;
|
|
box-shadow: none;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.app-session-overlay {
|
|
max-width: calc(100vw - 5rem);
|
|
max-height: calc(100vh - 5rem);
|
|
border-radius: 1rem;
|
|
}
|
|
}
|
|
|
|
/* Fullscreen mode */
|
|
.app-session-fullscreen {
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 0 !important;
|
|
max-width: none !important;
|
|
max-height: none !important;
|
|
}
|
|
|
|
/* Shared */
|
|
.app-session-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 8px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
transition: all 0.15s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
.app-session-btn:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
color: white;
|
|
}
|
|
.app-session-btn:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Mode dropdown */
|
|
.mode-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 10px 14px;
|
|
font-size: 13px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
transition: all 0.15s ease;
|
|
text-align: left;
|
|
}
|
|
.mode-option:hover {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: white;
|
|
}
|
|
.mode-option-active {
|
|
color: #fb923c;
|
|
background: rgba(251, 146, 60, 0.08);
|
|
}
|
|
|
|
.menu-fade-enter-active,
|
|
.menu-fade-leave-active {
|
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
}
|
|
.menu-fade-enter-from,
|
|
.menu-fade-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
|
|
.content-fade-enter-active,
|
|
.content-fade-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.content-fade-enter-from,
|
|
.content-fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.app-session-frame-scroll-host {
|
|
overflow: auto;
|
|
overscroll-behavior: contain;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
|
@media (max-width: 767px) {
|
|
.app-session-root {
|
|
height: 100%;
|
|
}
|
|
.app-session-inline {
|
|
height: 100%;
|
|
}
|
|
.app-session-overlay,
|
|
.app-session-fullscreen {
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
padding-top: var(--safe-area-top, env(safe-area-inset-top, 0px));
|
|
background: black;
|
|
}
|
|
.app-session-panel.glass-card {
|
|
border: none !important;
|
|
border-radius: 0 !important;
|
|
box-shadow: none !important;
|
|
}
|
|
.app-session-backdrop-overlay {
|
|
padding: 0;
|
|
backdrop-filter: none;
|
|
background: black;
|
|
}
|
|
.app-session-frame-safe {
|
|
flex: none !important;
|
|
height: calc(100vh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
|
height: calc(100dvh - var(--app-session-mobile-bar-height, 84px) - var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
|
padding-bottom: 0;
|
|
}
|
|
}
|
|
|
|
/* Mobile bottom browser bar — sized like the main tab bar.
|
|
Uses !important-free display so Tailwind md:hidden can override. */
|
|
@media (min-width: 768px) {
|
|
.app-session-mobile-bar { display: none !important; }
|
|
}
|
|
.app-session-mobile-bar {
|
|
display: flex;
|
|
position: fixed;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 2600;
|
|
justify-content: space-around;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
min-height: var(--app-session-mobile-bar-height, 84px);
|
|
padding: 10px 16px;
|
|
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
|
background: rgba(0, 0, 0, 0.25);
|
|
backdrop-filter: blur(18px);
|
|
-webkit-backdrop-filter: blur(18px);
|
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
transform: translateZ(0);
|
|
}
|
|
|
|
.app-session-inline .app-session-mobile-bar {
|
|
position: absolute;
|
|
z-index: 20;
|
|
}
|
|
|
|
|
|
.app-session-bar-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 52px;
|
|
height: 52px;
|
|
min-height: 52px;
|
|
border-radius: 13px;
|
|
color: rgba(255, 255, 255, 0.65);
|
|
transition: color 0.15s ease, background 0.15s ease;
|
|
}
|
|
.app-session-bar-btn svg {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
.app-session-bar-btn:active {
|
|
color: white;
|
|
background: rgba(255, 255, 255, 0.12);
|
|
}
|
|
</style>
|