Replace the instant-jump swipe with a live drag on touch: the current tab content translates with the finger in real time (translateX driven by touchmove), a peek chip at the edge shows the destination tab, and on release the sheet animates to the next tab or snaps back. Horizontal vs vertical travel is decided after 12px so normal page scrolling is untouched; touch-action:pan-y keeps horizontal gestures JS-owned. Radar still pans its map instead of switching tabs.
764 lines
20 KiB
Svelte
764 lines
20 KiB
Svelte
<script>
|
|
import { app } from './lib/stores/app.svelte.js'
|
|
import { notifications } from './lib/stores/notifications.svelte.js'
|
|
import LocationSidebar from './components/LocationSidebar.svelte'
|
|
import CurrentWeather from './components/CurrentWeather.svelte'
|
|
import HourlyForecast from './components/HourlyForecast.svelte'
|
|
import DailyForecast from './components/DailyForecast.svelte'
|
|
import WeatherDetail from './components/WeatherDetail.svelte'
|
|
import PrecipitationRadar from './components/PrecipitationRadar.svelte'
|
|
import NotificationBell from './components/NotificationBell.svelte'
|
|
import SettingsDialog from './components/SettingsDialog.svelte'
|
|
import AddLocationDialog from './components/AddLocationDialog.svelte'
|
|
import { parseHash, viewToHash, VALID_VIEWS } from './lib/router.js'
|
|
|
|
// Hash-routable tab: initialize from the URL hash so a shared link like
|
|
// #/radar opens straight to that tab.
|
|
let viewMode = $state(parseHash(typeof window !== 'undefined' ? window.location.hash : ''))
|
|
|
|
/** Switch the active tab and reflect it in the URL hash. */
|
|
function setView(v) {
|
|
viewMode = v
|
|
if (typeof window !== 'undefined') {
|
|
window.location.hash = viewToHash(v)
|
|
}
|
|
}
|
|
|
|
// Keep viewMode in sync with the hash — covers browser back/forward and a
|
|
// manually edited URL. Guarded so we never re-enter on the same value.
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return
|
|
const onHash = () => {
|
|
const v = parseHash(window.location.hash)
|
|
if (v !== viewMode) viewMode = v
|
|
}
|
|
window.addEventListener('hashchange', onHash)
|
|
return () => window.removeEventListener('hashchange', onHash)
|
|
})
|
|
|
|
// --- Mobile swipe-to-switch tabs (live drag + visual feedback) ---
|
|
// Horizontal swipe on the content area moves between tabs (touch only). The
|
|
// content translates with the finger in real time, showing a "peek" chip of
|
|
// the destination, then animates to the next tab (or snaps back) on release.
|
|
// Gated to non-radar views: the radar map handles its own pan gestures via
|
|
// pointer events, so a swipe there should pan the map, not switch tabs.
|
|
const SWIPE_THRESHOLD = 70 // px of horizontal travel before it counts as a swipe
|
|
const AXIS_THRESHOLD = 12 // travel before we decide horizontal vs vertical
|
|
const SWIPE_TIMEOUT = 160 // ms for the snap-back / snap-forward animation
|
|
const TAB_LABELS = { current: 'Now', hourly: 'Hourly', daily: 'Daily', detail: 'More', radar: 'Radar' }
|
|
|
|
let contentEl = $state(null) // bound to .content-area, gives travel distance
|
|
let swiping = $state(false) // a touch drag is in progress
|
|
let swipeAnchor = $state(null) // { x, y } of the starting touch
|
|
let swipeDX = $state(0) // live horizontal travel in px (drives the transform)
|
|
let swipeLock = $state(null) // 'x' | 'y' | null once the axis is decided
|
|
let settling = $state(false) // true while animating on release, enables CSS transition
|
|
|
|
const clampIdx = (i) => Math.min(Math.max(i, 0), VALID_VIEWS.length - 1)
|
|
|
|
// The destination view to hint at as the finger drags (only after we've
|
|
// committed to a horizontal swipe, so vertical scrolling shows nothing).
|
|
const peekView = $derived(
|
|
swiping && swipeLock === 'x' && VALID_VIEWS.includes(viewMode)
|
|
? VALID_VIEWS[clampIdx(VALID_VIEWS.indexOf(viewMode) + (swipeDX < 0 ? 1 : -1))]
|
|
: null
|
|
)
|
|
|
|
function onSwipeStart(e) {
|
|
if (viewMode === 'radar' || !e.touches || !e.touches[0]) return
|
|
const t = e.touches[0]
|
|
swipeAnchor = { x: t.clientX, y: t.clientY }
|
|
swipeDX = 0
|
|
swipeLock = null
|
|
settling = false
|
|
swiping = true
|
|
}
|
|
|
|
function onSwipeMove(e) {
|
|
if (!swiping || !swipeAnchor) return
|
|
const t = e.touches[0]
|
|
const dx = t.clientX - swipeAnchor.x
|
|
const dy = t.clientY - swipeAnchor.y
|
|
// Decide the axis the first time we travel far enough; once locked to Y we
|
|
// leave default scrolling alone and never pop the peek nipple.
|
|
if (!swipeLock) {
|
|
if (Math.abs(dx) > AXIS_THRESHOLD && Math.abs(dx) > Math.abs(dy)) swipeLock = 'x'
|
|
else if (Math.abs(dy) > AXIS_THRESHOLD) swipeLock = 'y'
|
|
}
|
|
if (swipeLock === 'x') {
|
|
e.preventDefault() // stop the page from scrolling while we drag sideways
|
|
swipeDX = dx
|
|
} else if (swipeLock === 'y') {
|
|
swipeDX = 0 // let vertical scroll pass through
|
|
}
|
|
}
|
|
|
|
function finishSwipe(e) {
|
|
if (!swiping) return
|
|
const t = e.changedTouches && e.changedTouches[0]
|
|
const dx = swipeLock === 'x' ? (t ? t.clientX - swipeAnchor.x : swipeDX) : swipeDX
|
|
const dy = t ? t.clientY - swipeAnchor.y : 0
|
|
const horizontal = swipeLock === 'x' && Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dx) >= Math.abs(dy)
|
|
|
|
if (horizontal && viewMode !== 'radar') {
|
|
const idx = VALID_VIEWS.indexOf(viewMode)
|
|
const step = dx < 0 ? 1 : -1 // left swipe = next, right = previous
|
|
const next = VALID_VIEWS[clampIdx(idx + step)]
|
|
if (next !== viewMode) {
|
|
// Animate the current sheet the rest of the way off-screen, then swap.
|
|
settling = true
|
|
swipeDX = (contentEl ? contentEl.clientWidth : 420) * step * -1
|
|
setTimeout(() => {
|
|
setView(next)
|
|
swiping = false
|
|
swipeDX = 0
|
|
swipeLock = null
|
|
swipeAnchor = null
|
|
settling = false
|
|
}, SWIPE_TIMEOUT)
|
|
return
|
|
}
|
|
}
|
|
// Not a switch: ease the sheet back to rest.
|
|
settling = true
|
|
swipeDX = 0
|
|
setTimeout(() => {
|
|
swiping = false
|
|
swipeLock = null
|
|
swipeAnchor = null
|
|
settling = false
|
|
}, SWIPE_TIMEOUT)
|
|
}
|
|
|
|
function cancelSwipe(e) {
|
|
if (!swiping) return
|
|
settling = true
|
|
swipeDX = 0
|
|
setTimeout(() => {
|
|
swiping = false
|
|
swipeLock = null
|
|
swipeAnchor = null
|
|
settling = false
|
|
}, SWIPE_TIMEOUT)
|
|
}
|
|
|
|
const slideStyle = $derived(swipeDX ? `transform: translateX(${swipeDX}px)` : '')
|
|
|
|
let initDone = $state(false)
|
|
let geoError = $state('')
|
|
|
|
// Initialize app on mount
|
|
$effect(() => {
|
|
initApp()
|
|
})
|
|
|
|
async function initApp() {
|
|
await app.init()
|
|
|
|
// If no locations exist, try to geolocate
|
|
if (app.locations.length === 0) {
|
|
await tryGeolocation()
|
|
}
|
|
|
|
// Trigger alert analysis after initial forecast
|
|
if (app.forecastData) {
|
|
notifications.analyze()
|
|
}
|
|
|
|
initDone = true
|
|
}
|
|
|
|
// Re-analyze alerts whenever forecast data changes
|
|
$effect(() => {
|
|
if (app.forecastData && app.settings.alertsEnabled) {
|
|
notifications.analyze()
|
|
}
|
|
})
|
|
|
|
// Restart the auto-refresh timer whenever the interval preference changes
|
|
$effect(() => {
|
|
app.settings.refreshInterval
|
|
app.startAutoRefresh()
|
|
})
|
|
|
|
async function tryGeolocation() {
|
|
if (!navigator.geolocation) {
|
|
geoError = 'Geolocation not supported. Add a location to get started.'
|
|
return
|
|
}
|
|
|
|
try {
|
|
const position = await new Promise((resolve, reject) => {
|
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
|
enableHighAccuracy: true,
|
|
timeout: 10000,
|
|
maximumAge: 600000, // 10 minutes
|
|
})
|
|
})
|
|
|
|
const { latitude, longitude } = position.coords
|
|
const { addLocation, setCurrentLocation } = await import('./lib/storage/db.js')
|
|
|
|
const id = await addLocation({
|
|
name: 'Current Location',
|
|
lat: Math.round(latitude * 10000) / 10000,
|
|
lon: Math.round(longitude * 10000) / 10000,
|
|
isCurrent: true,
|
|
order: 0,
|
|
})
|
|
|
|
await setCurrentLocation(id)
|
|
await app.refreshLocations()
|
|
app.selectedLocationId = id
|
|
await app.refreshForecast()
|
|
} catch (e) {
|
|
geoError = 'Location access denied. Add a location manually.'
|
|
}
|
|
}
|
|
|
|
async function handleAddLocation(location) {
|
|
const { addLocation } = await import('./lib/storage/db.js')
|
|
await addLocation({
|
|
name: location.name,
|
|
lat: location.latitude,
|
|
lon: location.longitude,
|
|
isCurrent: false,
|
|
})
|
|
await app.refreshLocations()
|
|
app.showAddLocation = false
|
|
app.geocodingResults = []
|
|
|
|
// Select the newly added location
|
|
const updated = await import('./lib/storage/db.js').then(m => m.getLocations())
|
|
const newest = updated[updated.length - 1]
|
|
if (newest) {
|
|
app.selectLocation(newest.id)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="app-shell">
|
|
<!-- Mobile header -->
|
|
<div class="mobile-header">
|
|
<button
|
|
class="btn-icon"
|
|
onclick={() => app.sidebarOpen = !app.sidebarOpen}
|
|
aria-label="Toggle menu"
|
|
>
|
|
{app.sidebarOpen ? '✕' : '☰'}
|
|
</button>
|
|
<span class="mobile-title">
|
|
{app.selectedLocation?.name || 'WeatherLens'}
|
|
</span>
|
|
<button class="btn-icon" onclick={() => app.showSettings = true} aria-label="Settings" title="Settings">
|
|
⚙️
|
|
</button>
|
|
<NotificationBell />
|
|
</div>
|
|
|
|
<!-- Sidebar overlay for mobile -->
|
|
{#if app.sidebarOpen}
|
|
<button
|
|
class="sidebar-overlay"
|
|
onclick={() => app.sidebarOpen = false}
|
|
aria-label="Close sidebar"
|
|
></button>
|
|
{/if}
|
|
|
|
<!-- Sidebar -->
|
|
<aside class="sidebar" class:open={app.sidebarOpen}>
|
|
<LocationSidebar />
|
|
</aside>
|
|
|
|
<!-- Main content -->
|
|
<main class="main-content">
|
|
<!-- Desktop header -->
|
|
<div class="top-bar">
|
|
<div class="top-bar-title">
|
|
<h1>{app.selectedLocation?.name || 'WeatherLens'}</h1>
|
|
{#if app.selectedLocation?.isCurrent}
|
|
<span class="current-badge">📍 Current</span>
|
|
{/if}
|
|
</div>
|
|
<div class="top-bar-actions">
|
|
<NotificationBell />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error banner -->
|
|
{#if app.error}
|
|
<div class="error-banner animate-slide-down">
|
|
<span>⚠️ {app.error}</span>
|
|
<button class="btn-icon" onclick={() => app.error = ''} aria-label="Dismiss">✕</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Geolocation error on first load -->
|
|
{#if geoError && !app.forecastData}
|
|
<div class="empty-state">
|
|
<div class="empty-icon">🌍</div>
|
|
<h2>Welcome to WeatherLens</h2>
|
|
<p class="text-muted">{geoError}</p>
|
|
<button class="btn btn-primary mt-3" onclick={() => app.showAddLocation = true}>+ Add a Location</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Loading state -->
|
|
{#if app.loading && !app.forecastData}
|
|
<div class="loading-state">
|
|
<div class="loading-spinner">⏳</div>
|
|
<p class="text-muted mt-2">Loading weather data...</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Weather content -->
|
|
{#if app.forecastData && app.selectedLocation}
|
|
<!-- Mobile bottom nav -->
|
|
<div class="mobile-nav">
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'current'}
|
|
onclick={() => setView('current')}
|
|
>Now</button>
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'hourly'}
|
|
onclick={() => setView('hourly')}
|
|
>Hourly</button>
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'daily'}
|
|
onclick={() => setView('daily')}
|
|
>Daily</button>
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'detail'}
|
|
onclick={() => setView('detail')}
|
|
>More</button>
|
|
<button
|
|
class="nav-btn"
|
|
class:active={viewMode === 'radar'}
|
|
onclick={() => setView('radar')}
|
|
>Radar</button>
|
|
</div>
|
|
|
|
<!-- Desktop tab bar -->
|
|
<div class="desktop-tabs">
|
|
<button class="tab-btn" class:active={viewMode === 'current'} onclick={() => setView('current')}>Current</button>
|
|
<button class="tab-btn" class:active={viewMode === 'hourly'} onclick={() => setView('hourly')}>Hourly</button>
|
|
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => setView('daily')}>7-Day Forecast</button>
|
|
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => setView('detail')}>Details</button>
|
|
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => setView('radar')}>Radar</button>
|
|
</div>
|
|
|
|
<!-- Content area -->
|
|
<div
|
|
class="content-area"
|
|
class:swiping={swiping}
|
|
bind:this={contentEl}
|
|
ontouchstart={onSwipeStart}
|
|
ontouchmove={onSwipeMove}
|
|
ontouchend={finishSwipe}
|
|
ontouchcancel={cancelSwipe}
|
|
>
|
|
<div
|
|
class="swipe-layer"
|
|
class:settle={settling}
|
|
style={slideStyle}
|
|
>
|
|
{#if peekView && TAB_LABELS[peekView]}
|
|
<span
|
|
class="swipe-peek"
|
|
class:from-left={swipeDX >= 0}
|
|
class:from-right={swipeDX < 0}
|
|
aria-hidden="true"
|
|
>{TAB_LABELS[peekView]}</span>
|
|
{/if}
|
|
{#if viewMode === 'current'}
|
|
<div class="animate-fade-in">
|
|
<CurrentWeather />
|
|
</div>
|
|
{:else if viewMode === 'hourly'}
|
|
<div class="animate-fade-in">
|
|
<HourlyForecast />
|
|
</div>
|
|
{:else if viewMode === 'daily'}
|
|
<div class="animate-fade-in">
|
|
<DailyForecast />
|
|
</div>
|
|
{:else if viewMode === 'detail'}
|
|
<div class="animate-fade-in">
|
|
<WeatherDetail />
|
|
</div>
|
|
{:else if viewMode === 'radar'}
|
|
<div class="animate-fade-in">
|
|
<PrecipitationRadar />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
|
|
<!-- Settings dialog -->
|
|
{#if app.showSettings}
|
|
<SettingsDialog onClose={() => app.showSettings = false} />
|
|
{/if}
|
|
|
|
<!-- Add location dialog -->
|
|
{#if app.showAddLocation}
|
|
<AddLocationDialog
|
|
onClose={() => {
|
|
app.showAddLocation = false
|
|
app.geocodingResults = []
|
|
}}
|
|
onAdd={handleAddLocation}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.app-shell {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
max-width: 100vw;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Mobile header */
|
|
.mobile-header {
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: max(8px, var(--safe-top)) 8px 8px max(12px, var(--safe-top));
|
|
background: var(--color-surface);
|
|
border-bottom: 1px solid var(--color-border);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.mobile-title {
|
|
font-weight: 700;
|
|
font-size: 0.95rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex: 1;
|
|
margin: 0 8px;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.sidebar {
|
|
width: 280px;
|
|
min-width: 280px;
|
|
background: var(--color-surface);
|
|
border-right: 1px solid var(--color-border);
|
|
height: 100vh;
|
|
height: 100dvh;
|
|
position: sticky;
|
|
top: 0;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 60;
|
|
}
|
|
|
|
.sidebar-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 59;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
margin: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Main content */
|
|
.main-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
.top-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 20px;
|
|
background: var(--color-surface);
|
|
border-bottom: 1px solid var(--color-border);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 50;
|
|
}
|
|
|
|
.top-bar-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.top-bar-title h1 {
|
|
font-size: 1.1rem;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.current-badge {
|
|
font-size: 0.75rem;
|
|
background: rgba(56, 189, 248, 0.15);
|
|
color: var(--color-primary);
|
|
padding: 2px 8px;
|
|
border-radius: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.top-bar-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
|
|
/* Error banner */
|
|
.error-banner {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin: 8px 16px;
|
|
padding: 10px 14px;
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
border-radius: var(--radius-md);
|
|
color: #fca5a5;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Empty state */
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
padding: 40px 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 4rem;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.empty-state h2 {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
/* Loading state */
|
|
.loading-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
padding: 60px 20px;
|
|
}
|
|
|
|
.loading-spinner {
|
|
font-size: 3rem;
|
|
animation: spin 1.5s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Desktop tabs */
|
|
.desktop-tabs {
|
|
display: flex;
|
|
gap: 0;
|
|
padding: 0 20px;
|
|
border-bottom: 1px solid var(--color-border);
|
|
background: var(--color-bg);
|
|
}
|
|
|
|
.tab-btn {
|
|
padding: 10px 16px;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: color var(--transition), border-color var(--transition);
|
|
}
|
|
|
|
.tab-btn:hover {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.tab-btn.active {
|
|
color: var(--color-primary);
|
|
border-bottom-color: var(--color-primary);
|
|
}
|
|
|
|
/* Mobile bottom nav */
|
|
.mobile-nav {
|
|
display: none;
|
|
position: sticky;
|
|
top: 52px;
|
|
z-index: 90;
|
|
padding: 4px 12px;
|
|
gap: 2px;
|
|
background: var(--color-surface);
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.nav-btn {
|
|
flex: 1;
|
|
padding: 8px 4px;
|
|
border: none;
|
|
border-radius: var(--radius-md);
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background-color var(--transition), color var(--transition);
|
|
white-space: nowrap;
|
|
text-align: center;
|
|
}
|
|
|
|
.nav-btn.active {
|
|
background: rgba(56, 189, 248, 0.12);
|
|
color: var(--color-primary);
|
|
}
|
|
|
|
.content-area {
|
|
flex: 1;
|
|
padding: 16px;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
min-width: 0;
|
|
}
|
|
|
|
.swipe-layer {
|
|
position: relative;
|
|
min-height: 100%;
|
|
will-change: transform;
|
|
touch-action: pan-y;
|
|
}
|
|
|
|
.swipe-layer.settle {
|
|
transition: transform 160ms ease-out;
|
|
}
|
|
|
|
.content-area.swiping {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.swipe-peek {
|
|
position: absolute;
|
|
top: 8px;
|
|
z-index: 5;
|
|
padding: 5px 10px;
|
|
border-radius: var(--radius-md);
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
color: var(--color-primary);
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.02em;
|
|
text-transform: uppercase;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
|
opacity: 0;
|
|
animation: peek-in 160ms ease-out forwards;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.swipe-peek.from-left {
|
|
left: 8px;
|
|
}
|
|
|
|
.swipe-peek.from-right {
|
|
right: 8px;
|
|
}
|
|
|
|
@keyframes peek-in {
|
|
from { opacity: 0; transform: translateX(10px); }
|
|
to { opacity: 1; transform: translateX(0); }
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1023px) {
|
|
.app-shell {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.mobile-header {
|
|
display: flex;
|
|
}
|
|
|
|
.top-bar {
|
|
display: none;
|
|
}
|
|
|
|
.sidebar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
bottom: 0;
|
|
z-index: 60;
|
|
display: none;
|
|
transition: none;
|
|
width: min(300px, 85vw);
|
|
min-width: 0;
|
|
height: 100dvh;
|
|
padding-top: var(--safe-top);
|
|
}
|
|
|
|
.sidebar.open {
|
|
display: flex;
|
|
}
|
|
|
|
.sidebar-overlay {
|
|
display: block;
|
|
}
|
|
|
|
.desktop-tabs {
|
|
display: none;
|
|
}
|
|
|
|
.mobile-nav {
|
|
display: flex;
|
|
}
|
|
|
|
.content-area {
|
|
padding: 8px 8px max(16px, var(--safe-bottom));
|
|
}
|
|
|
|
.error-banner {
|
|
margin: 4px 8px;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.mobile-nav {
|
|
display: none;
|
|
}
|
|
}
|
|
</style>
|