Adds a Storms tab driven by three keyless, CORS-enabled feeds: - api.weather.gov/alerts/active?point=… for watches/warnings/advisories, with a banner above the content for tornado/severe/Extreme alerts - SPC Day 1-3 categorical outlooks plus Day 1 tornado/hail/wind probabilities, resolved by point-in-polygon (nolyr GeoJSON) - NWS Local Storm Reports via IEM (bounding box + radius cut), nearest first New: src/lib/api/storm.js (pure helpers + cached fetchers), src/lib/stores/storm.svelte.js (5-min polling, stale-response guard), src/components/StormWatch.svelte. Router/nav gain a 'storms' view. Tests: geometry/parsing (16), store (7), component render (3). Docs: AGENTS.md storm section + API notes.
846 lines
22 KiB
Svelte
846 lines
22 KiB
Svelte
<script>
|
||
import { app } from './lib/stores/app.svelte.js'
|
||
import { notifications } from './lib/stores/notifications.svelte.js'
|
||
import { storm } from './lib/stores/storm.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 StormWatch from './components/StormWatch.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
|
||
|
||
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).
|
||
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()
|
||
notifications.refreshAdvisories() // official NWS advisories (cached, best-effort)
|
||
}
|
||
|
||
// Storm feeds (alerts, SPC outlook, storm reports) for the location.
|
||
storm.watch()
|
||
storm.start()
|
||
|
||
initDone = true
|
||
}
|
||
|
||
// Re-analyze alerts whenever forecast data changes
|
||
$effect(() => {
|
||
if (app.forecastData && app.settings.alertsEnabled) {
|
||
notifications.analyze()
|
||
notifications.refreshAdvisories() // official NWS advisories (cached, best-effort)
|
||
}
|
||
})
|
||
|
||
// Refresh storm data whenever the selected location changes. The store skips
|
||
// the fetch when it already holds fresh data for that point.
|
||
$effect(() => {
|
||
if (!initDone) return
|
||
if (app.selectedLocation) storm.watch()
|
||
else storm.reset()
|
||
})
|
||
|
||
// 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}
|
||
|
||
<!-- Non-blocking refresh indicator (location switch while data is shown) -->
|
||
{#if app.loading && app.forecastData}
|
||
<div class="refreshing-overlay" aria-live="polite" aria-busy="true">
|
||
<div class="refreshing-chip">
|
||
<span class="loading-spinner small">⏳</span>
|
||
<span>Updating…</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Weather content -->
|
||
{#if app.forecastData && app.selectedLocation}
|
||
<!-- Active tornado / severe-thunderstorm / extreme alert banner -->
|
||
{#if storm.banner}
|
||
<button
|
||
class="storm-banner animate-slide-down"
|
||
onclick={() => setView('storms')}
|
||
aria-label="{storm.banner.event} — open storm details"
|
||
>
|
||
<span class="storm-banner-icon">⚠️</span>
|
||
<span class="storm-banner-text">
|
||
<strong>{storm.banner.event}</strong>
|
||
<span class="storm-banner-area">{storm.banner.areaDesc.split(';').slice(0, 2).join(' · ')}</span>
|
||
</span>
|
||
<span class="storm-banner-cta">Details ›</span>
|
||
</button>
|
||
{/if}
|
||
|
||
<!-- 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>
|
||
<button
|
||
class="nav-btn"
|
||
class:active={viewMode === 'storms'}
|
||
onclick={() => setView('storms')}
|
||
>Storms</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>
|
||
<button class="tab-btn" class:active={viewMode === 'storms'} onclick={() => setView('storms')}>Storms</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 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>
|
||
{:else if viewMode === 'storms'}
|
||
<div class="animate-fade-in">
|
||
<StormWatch />
|
||
</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;
|
||
}
|
||
|
||
/* Non-blocking "Updating…" chip shown while a location change refetches. */
|
||
.refreshing-overlay {
|
||
position: sticky;
|
||
top: 8px;
|
||
z-index: 45;
|
||
display: flex;
|
||
justify-content: center;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.refreshing-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
border-radius: 999px;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.loading-spinner.small {
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* Active severe-alert banner */
|
||
.storm-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
text-align: left;
|
||
margin: 8px 0 0;
|
||
padding: 10px 14px;
|
||
background: rgba(239, 68, 68, 0.12);
|
||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||
border-radius: var(--radius-md);
|
||
color: var(--color-text);
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.storm-banner-icon { font-size: 1.1rem; }
|
||
|
||
.storm-banner-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
font-size: 0.82rem;
|
||
}
|
||
|
||
.storm-banner-area {
|
||
font-size: 0.72rem;
|
||
color: var(--color-text-secondary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.storm-banner-cta {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: #fca5a5;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
@media (max-width: 1023px) {
|
||
.storm-banner {
|
||
margin: 6px 8px 0;
|
||
}
|
||
}
|
||
|
||
@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;
|
||
}
|
||
|
||
|
||
/* 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>
|