-
-
-
-
-
-
+
+
+
+
+ 🌅
+ --
+
+
+
+ 🌇
+ --
+
+
+
+ 🌙
+ --
+ --
+
-
-
-
-
+
+
+ Wind & Atmosphere
+
+
-
-
-
-
+
+
+ More Details ›
+
+
+
+
+
+ What to Bring
+
Radar
-
+
@@ -116,7 +213,6 @@
Hourly Forecast
next 24 hours
-
--
@@ -124,9 +220,7 @@
7-Day Forecast
-
-
-
+
@@ -138,64 +232,138 @@
-
-
-
-
-
-
-
-
-
--
+
+
+
+
+
+
+
+
+
+
+ Temp --
+ AQI --
+ Sun -- • Moon --
+
+
+
+
+
-
-
Radar tiles © RainViewer · Map © OpenStreetMap
-
-
-
-
- Maps
-
-
-
-
-
-
-
-
-
-
-
-
Use star for default, arrows to reorder.
Auto-detect location on startup
-
@@ -239,83 +407,733 @@
--
-
- Default city
- None
-
-
Units
-
-
- Lightning strikes on map
-
-
- Off
-
-
-
-
- Wind overlay on map
-
-
- Off
-
-
-
-
- High contrast mode
-
-
- Off
-
-
-
+
+
+
+
Weather Learning Guide
+
This page explains what each reading means and how to use it in daily decisions.
+
+
+
+
+
+ Temperature
+ Air temperature measured about 2 meters above ground. Use it for clothing and comfort.
+
+
+ Feels Like
+ Adjusted temperature that includes wind chill (cold) or heat index (hot/humid).
+
+
+ Wind Speed & Direction
+ Speed is how fast air moves. Direction is where wind comes from (for example, West means from the west).
+
+
+ Wind Gusts
+ Short bursts stronger than average wind. Important for driving, biking, and outdoor safety.
+
+
+ Humidity
+ How much moisture is in the air. Higher humidity can make warm weather feel hotter.
+
+
+ Dew Point
+ Temperature at which air becomes saturated. Higher dew point means a stickier, more humid feel.
+
+
+ Pressure
+ Atmospheric pressure in hPa. Falling pressure can signal unsettled weather.
+
+
+ Visibility
+ How far you can clearly see. Low visibility often occurs with fog, heavy rain, or snow.
+
+
+ Cloud Cover
+ Percent of sky covered by clouds. 0% is clear; 100% is overcast.
+
+
+ Radar
+ Shows precipitation intensity and motion. Use animation to see where rain/snow is moving.
+
+
+ AQI (Air Quality Index)
+ Higher AQI means worse air. Sensitive groups should reduce outdoor activity when AQI is elevated.
+
+
+ Sunrise / Sunset / Moon
+ Useful for planning daylight hours, photography, and evening conditions.
+
+
+
+
+
AQI Severity Bands (US AQI)
+
Higher values mean more pollution risk, especially for sensitive groups.
+
+
+
+ 0-50
+ Good
+ Air is clean for most people.
+
+
+
+ 51-100
+ Moderate
+ Unusually sensitive people may notice symptoms.
+
+
+
+ 101-150
+ Unhealthy for Sensitive Groups
+ Children, elderly, and respiratory patients should limit exertion.
+
+
+
+ 151-200
+ Unhealthy
+ Everyone may begin to feel effects; reduce prolonged outdoor activity.
+
+
+
+ 201-300
+ Very Unhealthy
+ Health alert conditions; stay indoors when possible.
+
+
+
+ 301+
+ Hazardous
+ Emergency conditions; avoid outdoor exposure.
+
+
+
+
+
+
Wind Threshold Guide
+
Use sustained wind for general conditions and gusts for sudden impacts.
+
+
+
+ 0-7 mph
+ Calm to Light
+ Little effect on activities.
+
+
+
+ 8-18 mph
+ Breezy
+ Noticeable wind; light debris may move.
+
+
+
+ 19-31 mph
+ Windy
+ Harder for high-profile vehicles and cyclists.
+
+
+
+ 32-46 mph
+ Strong
+ Tree limbs can break; caution outdoors.
+
+
+
+ 47+ mph
+ Severe / Damaging
+ Potential structural impacts and power interruptions.
+
+
+
+
+
+
Radar Intensity Legend (General)
+
Color shades generally map to precipitation intensity from light to extreme.
+
+
+
+ Light Blue / Green
+ Light Rain or Snow
+ Usually low impact, but can reduce visibility.
+
+
+
+ Yellow
+ Moderate Precipitation
+ Steadier rain/snow, pooling possible in poor drainage.
+
+
+
+ Orange / Red
+ Heavy Rain / Strong Cells
+ Rapid visibility drop and localized flooding risk.
+
+
+
+ Dark Red / Purple
+ Very Intense Storm Core
+ Severe weather possible; monitor warnings closely.
+
+
+
+
+
+
How To Read The Map Quickly
+
+ - Start with radar animation direction to see where precipitation is moving next 30-60 minutes.
+ - Check wind arrows for transport direction of smoke, storms, and temperature changes.
+ - Compare AQI and wind together: poor AQI plus low wind can mean slower air cleanup.
+ - Use sunrise/sunset values to plan travel and outdoor time before dark.
+ - Treat alerts as highest priority when present, even if current radar looks quiet.
+
+
+
+
+
Humidity & Dew Point Comfort Scale
+
Dew point is often the best quick signal for how sticky the air will feel.
+
+
+
+ Below 50 F
+ Dry / Comfortable
+ Most people feel comfortable, especially in shade.
+
+
+
+ 50-59 F
+ Pleasant
+ Slight humidity, still comfortable for most activity.
+
+
+
+ 60-64 F
+ Sticky
+ Many people begin to notice muggy conditions.
+
+
+
+ 65-69 F
+ Very Humid
+ Outdoor exertion can feel much harder.
+
+
+
+ 70 F+
+ Oppressive / Tropical
+ Heat stress risk rises quickly; hydrate and take breaks.
+
+
+
+
+
+
Pressure Trend Clues
+
The trend matters more than a single pressure number.
+
+ - Rising pressure usually signals stabilizing weather and improving conditions.
+ - Falling pressure often signals approaching storms, fronts, or unsettled weather.
+ - A rapid pressure drop can be a warning sign for stronger systems.
+ - Compare pressure changes with wind shifts and radar movement for better timing.
+
+
+
+
+
Visibility Guide
+
+
+
+ 6+ mi
+ Good
+ Normal driving and outdoor visibility.
+
+
+
+ 3-6 mi
+ Fair
+ Haze/light precipitation may reduce distant detail.
+
+
+
+ 1-3 mi
+ Poor
+ Use caution while driving; increase following distance.
+
+
+
+ Below 1 mi
+ Dangerous
+ Fog/heavy precip can make travel hazardous.
+
+
+
+
+
+
UV Index Risk Bands
+
+
+
+ 0-2
+ Low
+ Minimal protection needed for most people.
+
+
+
+ 3-5
+ Moderate
+ Use sunscreen and sunglasses.
+
+
+
+ 6-7
+ High
+ Limit midday sun and protect skin.
+
+
+
+ 8-10
+ Very High
+ Burn risk rises quickly; seek shade often.
+
+
+
+ 11+
+ Extreme
+ Unprotected skin can burn rapidly.
+
+
+
+
+
+
Alert Terms: Watch vs Warning vs Advisory
+
+
+ Term
+ Meaning
+ What To Do
+
+
+ Watch
+ Conditions are favorable.
+ Prepare and monitor updates.
+
+
+ Warning
+ Dangerous weather is happening or imminent.
+ Take action now to protect life and property.
+
+
+ Advisory
+ Hazardous conditions likely to cause inconvenience or minor impacts.
+ Use extra caution.
+
+
+
+
+
+
Precipitation Probability Basics
+
+ - A 30% rain chance means a 3-in-10 chance of measurable precipitation at your location.
+ - Probability does not equal duration: short heavy bursts can happen with moderate percentages.
+ - Use radar animation with hourly probability for best near-term planning.
+ - Snow type and accumulation depend strongly on near-surface temperature and layer depth aloft.
+
+
+
+
+
Reading Cloud Cover
+
+
+
+ 0-20%
+ Mostly Clear
+ Good sky visibility and strong daytime sun.
+
+
+
+ 21-60%
+ Partly Cloudy
+ Variable sun with passing clouds.
+
+
+
+ 61-85%
+ Mostly Cloudy
+ Limited sunshine and cooler daytime heating.
+
+
+
+ 86-100%
+ Overcast
+ Gray skies; often linked with steady precipitation systems.
+
+
+
+
+
+
Severe Weather Quick Signals
+
+ - Rapidly strengthening radar cores plus rising winds can indicate stronger storm potential.
+ - Sudden wind direction shifts near a storm can mark boundary passage or outflow.
+ - Frequent lightning, intense rain rates, and repeated cells over one area raise flash-flood concern.
+ - Always prioritize official warnings over visual interpretation.
+
+
+
+
+
Unit Conversion Quick Reference
+
+
+ Metric
+ US
+ Rule of Thumb
+
+
+ C
+ F
+ 20C is about 68F, 30C is about 86F.
+
+
+ km/h
+ mph
+ mph is roughly km/h divided by 1.6.
+
+
+ mm
+ in
+ 25 mm is about 1 inch.
+
+
+ km
+ mi
+ 1 mile is about 1.6 km.
+
+
+
+
+
+
Data Timing Notes
+
+ - Current values and hourly forecasts can update at different cadences depending on source.
+ - Radar frames can arrive every few minutes and may have occasional provider delays.
+ - A single outlier value should be interpreted with recent trend context when possible.
+
+
+
+
+
Personal Risk Profiles
+
These groups should use tighter limits when planning outdoor time.
+
+
+ Profile
+ Watch Closely
+ Suggested Action
+
+
+ Children
+ Heat index, AQI, lightning
+ Shorten play in heat/haze and move inside at thunder.
+
+
+ Older Adults
+ Heat, wind chill, poor AQI
+ Avoid peak heat/cold windows and reduce exertion.
+
+
+ Asthma/COPD
+ AQI, smoke transport wind
+ Limit outdoor effort when AQI rises above 100.
+
+
+ Outdoor Workers
+ Heat stress, lightning, strong gusts
+ Use hydration/rest cycles and stop work for nearby storms.
+
+
+ Pets
+ Hot pavement, cold wind, AQI
+ Shorten walks in extreme heat/cold and poor air.
+
+
+
+
+
+
Activity Decision Matrix
+
Use this quick matrix to decide if activity is generally good, caution, or postpone.
+
+
+ Activity
+ Caution Triggers
+ Postpone Triggers
+
+
+ Run / Bike
+ AQI 101+, gusts 25+ mph, heat index above 90 F
+ Lightning nearby, AQI 151+, visibility below 1 mile
+
+
+ Yard Work
+ Heat index 95+, sustained wind 25+ mph
+ Warning-level storms or severe gust risk
+
+
+ Commuting
+ Visibility 1-3 miles, heavy radar returns
+ Flooded roads, freezing rain, visibility below 1 mile
+
+
+ Grilling / Events
+ Gusts 20+ mph, rain cores approaching
+ Lightning or severe thunderstorm warnings
+
+
+ Long Drive
+ Strong crosswinds and moderate heavy precip
+ Ice accretion risk, flash-flood warnings, blizzard-like visibility
+
+
+
+
+
+
Lightning Safety Rules
+
+ - 30/30 rule: if thunder follows lightning within 30 seconds, move indoors immediately.
+ - Wait at least 30 minutes after the last thunder before resuming outdoor activity.
+ - Safe shelter means a fully enclosed building or hard-top vehicle with windows closed.
+ - Avoid trees, open fields, metal fences, and water during thunderstorms.
+ - If indoors, avoid corded electronics and plumbing during active lightning.
+
+
+
+
+
Flood and Heavy Rain Triggers
+
+ - Slow-moving radar cores repeatedly passing over one area can cause flash flooding.
+ - Urban areas flood faster due to hard surfaces and poor drainage.
+ - Rising stream or ditch levels plus continued heavy rain indicate escalating risk.
+ - Never drive through water-covered roads; depth and road damage are hard to judge.
+ - Turn around, do not drown: the safest route is always to avoid flooded crossings.
+
+
+
+
+
Winter Precipitation Type Guide
+
+
+ Type
+ Typical Setup
+ Common Impact
+
+
+ Snow
+ Colder layers top to bottom
+ Accumulation, lower visibility, slower travel
+
+
+ Sleet
+ Melting aloft then refreezing before ground
+ Pellet ice and slick roads
+
+
+ Freezing Rain
+ Warm layer aloft with shallow subfreezing surface
+ High-impact glaze ice on roads, trees, and power lines
+
+
+ Cold Rain
+ Surface above freezing
+ Wet roads without ice unless temps drop later
+
+
+
+
+
+
Forecast Confidence Explained
+
+
+ High Confidence
+ Models agree on timing and intensity. Day planning is usually stable.
+
+
+ Medium Confidence
+ General pattern is clear, but timing or totals may shift.
+
+
+ Low Confidence
+ Competing scenarios. Recheck often and plan with larger safety margins.
+
+
+
+
+
+
Local Climate Normals Context
+
+ - Compare today to normal seasonal values, not only yesterday.
+ - A temperature departure of plus/minus 10 F from normal is usually notable.
+ - Several wet or dry weeks in a row matter more than one unusual day.
+ - Use normals to judge whether current patterns are typical for your area and month.
+
+
+
+
+
FAQ
+
+
+ Why does radar show rain but I do not feel it yet?
+ Precipitation can evaporate before reaching the ground, or the core may pass just north/south of your exact location.
+
+
+ Why did temperature change quickly in one hour?
+ A front passage, cloud break, rain-cooled outflow, or wind direction shift can change temperature rapidly.
+
+
+ Why is wind gust much higher than sustained wind?
+ Sustained wind is an average over time, while gusts capture brief stronger bursts.
+
+
+ Why can AQI be poor even when sky looks clear?
+ Fine particles and ozone are not always visible. Transport winds can carry pollution long distances.
+
+
+ Why does forecast rain timing keep shifting?
+ Small speed changes in fronts or storm lines can move arrival by hours, especially in warm-season convection.
+
+
+ Why can one neighborhood get heavy rain while another stays dry?
+ Thunderstorms are highly localized. A slight shift in storm track can create large differences over short distances.
+
+
+ Why does it feel hotter than the listed air temperature?
+ High humidity reduces evaporative cooling from skin. Direct sun and low wind can make it feel even warmer.
+
+
+ Why is the app showing no precipitation while radar still has color?
+ Radar reflectivity may detect virga, non-reaching snow grains, or weak echoes that do not produce measurable ground accumulation.
+
+
+ What is the difference between weather watch and warning again?
+ A watch means conditions are favorable. A warning means hazardous weather is occurring or imminent and action should be taken now.
+
+
+ How often should I refresh during severe weather?
+ Check every few minutes when storms are nearby. Radar and alerts can update quickly as conditions evolve.
+
+
+ Why does wind direction matter for storms and smoke?
+ Wind direction controls where storm outflow, smoke, and pollution plumes travel, affecting local impact timing.
+
+
+ How can I tell if roads might flood soon?
+ Look for repeated heavy radar cells over the same area, poor drainage zones, and active flood alerts.
+
+
+ Why does freezing rain cause more damage than snow?
+ Freezing rain forms glaze ice on roads, trees, and power lines, creating severe traction and infrastructure risk.
+
+
+ Can good visibility still be dangerous for driving?
+ Yes. Wind gusts, black ice, or sudden hydroplaning can create hazards even when visibility appears acceptable.
+
+
+ What should I trust most when map layers disagree?
+ Prioritize official alerts first, then current observations/radar, then short-term forecast trends.
+
+
+ Why does sunrise or sunset time matter beyond daylight?
+ Temperature, visibility, and road conditions can change quickly around sunrise/sunset, and glare risk increases.
+
+
+ How should I plan outdoor exercise using this app?
+ Check AQI, temperature/feels-like, wind gusts, and radar trend together. Avoid peak heat, smoke, and nearby lightning.
+
+
+
+
+
+
Emergency Readiness Checklist
+
+ - Water, food, medications, flashlight, and batteries for at least 72 hours.
+ - Phone power bank charged before severe weather windows.
+ - Family meeting point and communication plan.
+ - Weather radio or alert-enabled phone notifications.
+ - Vehicle fuel and alternate route plan for flood or ice risk.
+ - Pet food, leash, and carrier ready for rapid relocation.
+
+
+
+
+
Glossary Quick Terms
+
+
Dew Point
Temperature where air saturates; stronger comfort indicator than humidity percent.
+
Heat Index
How hot it feels when humidity is combined with air temperature.
+
Wind Chill
How cold it feels when wind increases heat loss from skin.
+
Reflectivity
Radar measure of returned energy; higher values often indicate heavier precipitation.
+
Advection
Horizontal movement of air that transports heat, moisture, smoke, or haze.
+
Inversion
Layer where temperature rises with height, often trapping pollution near the surface.
+
Outflow Boundary
Cool air spreading from storms that can trigger new storm development.
+
Nowcasting
Very short-term forecasting focused on the next minutes to few hours.
+
+
+
+
Back to Top
+
+
+
+
-
+
diff --git a/app/public/style.css b/app/public/style.css
index 71c96f7..299f3cb 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -1,1031 +1,2090 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
- --ink: #102539;
- --ink-soft: #476a82;
- --ink-muted: #6f90a4;
- --paper: #e7f2f8;
- --paper-deep: #d6e7f1;
- --surface: rgba(255, 255, 255, 0.9);
- --surface-soft: rgba(255, 255, 255, 0.74);
- --line: rgba(96, 138, 166, 0.35);
- --brand: #f0722f;
- --brand-strong: #d75f1f;
- --info: #1d87bf;
- --ok: #1f9c79;
- --danger: #c84b2f;
- --radius-xl: 22px;
- --radius-lg: 16px;
- --radius-md: 12px;
- --shadow-lg: 0 14px 36px rgba(11, 35, 52, 0.16);
- --shadow-md: 0 8px 22px rgba(11, 35, 52, 0.12);
- --ui: 'Manrope', 'Avenir Next', 'Segoe UI', sans-serif;
- --display: 'Playfair Display', Georgia, serif;
+ --ink: #1a1a1a;
+ --ink-mid: #3d3730;
+ --ink-light: #5f5649;
+ --paper: #f5f2ec;
+ --paper-dark: #ece7db;
+ --paper-darker: #ddd6c7;
+ --border: #b9ae9b;
+ --accent: #c25a2a;
+ --cool: #2a6b8a;
+ --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
+ --serif: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
+ --card-bg: var(--paper-dark);
+ --card-bg-soft: var(--paper-darker);
+ --card-ink: var(--ink);
+ --card-sub: var(--ink-light);
+ --surface-border: 1px solid rgba(255, 255, 255, 0.08);
+ --surface-radius-lg: 16px;
+ --surface-radius-md: 12px;
+ --surface-shadow: 0 8px 18px rgba(0, 0, 0, 0.16);
}
@media (prefers-color-scheme: dark) {
:root {
- --ink: #ecf6fd;
- --ink-soft: #bed1dd;
- --ink-muted: #92aebf;
- --paper: #0b1721;
- --paper-deep: #122433;
- --surface: rgba(26, 46, 61, 0.88);
- --surface-soft: rgba(28, 49, 65, 0.7);
- --line: rgba(118, 152, 176, 0.34);
- --brand: #ff8d4d;
- --brand-strong: #ef7330;
- --info: #63c2ef;
- --ok: #38c89f;
- --danger: #f77257;
- --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.45);
- --shadow-md: 0 9px 22px rgba(0, 0, 0, 0.35);
+ --ink: #ece7dc;
+ --ink-mid: #d2c8b1;
+ --ink-light: #beb399;
+ --paper: #191510;
+ --paper-dark: #201c16;
+ --paper-darker: #2b2620;
+ --border: #3a342a;
+ --accent: #e07848;
+ --cool: #5ab0d8;
}
}
html, body {
min-height: 100%;
- font-family: var(--ui);
- font-size: 18px;
- line-height: 1.45;
- color: var(--ink);
background: var(--paper);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 16px;
+ line-height: 1.6;
+ scroll-behavior: smooth;
overflow-x: hidden;
}
-body {
- background:
- radial-gradient(circle at 8% -6%, rgba(255, 255, 255, 0.86), transparent 46%),
- radial-gradient(circle at 100% 0%, rgba(111, 184, 222, 0.25), transparent 35%),
- linear-gradient(180deg, var(--paper) 0%, var(--paper-deep) 100%);
- transition: background 0.6s ease;
-}
-
-body.weather-clear {
- background:
- radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.95), transparent 44%),
- radial-gradient(circle at 95% 5%, rgba(255, 214, 131, 0.4), transparent 30%),
- linear-gradient(180deg, #dff2ff 0%, #cbe6f7 45%, #b6d9ee 100%);
-}
-
-body.weather-cloud {
- background:
- radial-gradient(circle at 10% -5%, rgba(255, 255, 255, 0.75), transparent 46%),
- radial-gradient(circle at 85% 0%, rgba(175, 203, 220, 0.35), transparent 35%),
- linear-gradient(180deg, #d7e8f1 0%, #c6dbe7 55%, #b5cfde 100%);
-}
-
-body.weather-rain {
- background:
- radial-gradient(circle at 20% -8%, rgba(196, 220, 235, 0.45), transparent 45%),
- radial-gradient(circle at 100% 0%, rgba(109, 162, 191, 0.32), transparent 35%),
- linear-gradient(180deg, #a8c1d2 0%, #8eaabd 55%, #748fa5 100%);
-}
-
-body.weather-snow {
- background:
- radial-gradient(circle at 12% 0%, rgba(255, 255, 255, 0.9), transparent 45%),
- radial-gradient(circle at 92% 0%, rgba(225, 241, 252, 0.8), transparent 36%),
- linear-gradient(180deg, #ebf4fa 0%, #dfeef8 55%, #d2e4f1 100%);
-}
-
-body.weather-storm {
- background:
- radial-gradient(circle at 30% -10%, rgba(140, 160, 181, 0.36), transparent 44%),
- radial-gradient(circle at 100% 5%, rgba(80, 111, 142, 0.45), transparent 34%),
- linear-gradient(180deg, #6f8598 0%, #576d83 55%, #42566c 100%);
-}
-
-body.weather-fog {
- background:
- radial-gradient(circle at 15% 0%, rgba(241, 246, 249, 0.9), transparent 44%),
- radial-gradient(circle at 92% 0%, rgba(214, 224, 231, 0.6), transparent 36%),
- linear-gradient(180deg, #d9e3e9 0%, #cdd9e0 55%, #c0ced7 100%);
-}
-
-body.weather-night {
- background:
- radial-gradient(circle at 15% -10%, rgba(123, 145, 173, 0.2), transparent 44%),
- radial-gradient(circle at 100% 0%, rgba(74, 104, 131, 0.28), transparent 34%),
- linear-gradient(180deg, #0b1722 0%, #102131 55%, #13283b 100%);
-}
-
-a { color: var(--info); text-decoration: none; }
-a:hover { text-decoration: underline; }
-.hidden { display: none !important; }
-
.app {
- width: 100%;
- max-width: 100%;
- padding: calc(0.7rem + env(safe-area-inset-top, 0px)) 0.75rem calc(6.9rem + env(safe-area-inset-bottom, 0px));
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 2rem 1.5rem 4rem;
}
-/* Header */
+a { color: var(--cool); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+/* ── Masthead ── */
.masthead {
- position: sticky;
- top: env(safe-area-inset-top, 0px);
- z-index: 220;
- padding: 0.55rem 0.55rem 0.45rem;
- border: 1px solid var(--line);
- border-radius: var(--radius-xl);
- background: linear-gradient(180deg, var(--surface) 0%, var(--surface-soft) 100%);
- backdrop-filter: blur(9px);
- box-shadow: var(--shadow-md);
- margin-bottom: 0.6rem;
+ display: grid;
+ grid-template-columns: 1fr;
+ align-items: center;
+ justify-items: center;
+ gap: 0.8rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-lg);
+ background: linear-gradient(180deg, var(--card-bg-soft), var(--paper-dark));
+ color: var(--card-ink);
+ padding: 1rem 1.1rem;
+ margin-bottom: 1.5rem;
+ box-shadow: var(--surface-shadow);
}
.masthead h1 {
- font-family: var(--display);
- font-size: 1.7rem;
- letter-spacing: -0.3px;
- margin-bottom: 0.35rem;
- color: #111;
- text-shadow:
- 0 1px 2px rgba(255,255,255,0.7),
- 0 0 8px rgba(255,255,255,0.5);
+ font-family: var(--serif);
+ font-size: 2.35rem;
+ font-weight: 700;
+ letter-spacing: -0.5px;
+ color: var(--card-ink);
+ white-space: nowrap;
+ text-align: center;
}
.tagline {
- display: block;
- margin-top: 0.35rem;
- font-size: 0.76rem;
- letter-spacing: 0.04em;
- color: rgba(0,0,0,0.65);
- text-shadow: 0 1px 2px rgba(255,255,255,0.6);
+ font-size: 0.88rem;
+ letter-spacing: 0.08em;
+ color: var(--card-sub);
+ text-transform: uppercase;
+ white-space: nowrap;
}
-/* Bottom nav */
+/* ── Tabs ── */
.tabs {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- width: 100vw;
- max-width: 100vw;
- z-index: 300;
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 0.22rem;
- padding: 0.45rem 0.28rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
- background: rgba(9, 28, 40, 0.92);
- backdrop-filter: blur(12px);
- border-top: 1px solid rgba(121, 165, 191, 0.36);
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ overflow: hidden;
}
-@supports (bottom: env(safe-area-inset-bottom)) {
- .tabs {
- bottom: 0;
- padding-bottom: calc(0.45rem + env(safe-area-inset-bottom));
- }
-}
-
-.tab {
- border: none;
- border-radius: 12px;
- min-width: 0;
- min-height: 58px;
- padding: 0.32rem 0.15rem;
+/* ── Unified Map ── */
+.unified-map-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: var(--card-bg);
+ padding: 1rem;
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 0.24rem;
- background: rgba(255, 255, 255, 0.04);
- color: #d8eaf4;
- cursor: pointer;
- font-family: var(--ui);
+ gap: 1rem;
}
-.tab-icon {
- font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
- font-size: 1.18rem;
- line-height: 1;
+/* ── Map Controls ── */
+.map-controls-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ padding-bottom: 0.75rem;
+ border-bottom: var(--surface-border);
}
-.tab-text {
- font-size: 0.67rem;
- line-height: 1;
- letter-spacing: 0.02em;
- font-weight: 600;
-}
-
-.tab.active {
- color: #fff;
- background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%);
-}
-
-/* Search */
-.search-wrap {
- margin-bottom: 0.4rem;
-}
-
-.search-row {
+.map-controls-row {
display: flex;
align-items: center;
- flex-wrap: nowrap;
- gap: 0;
- border: 1px solid var(--line);
- border-radius: var(--radius-lg);
+ gap: 0.75rem;
+}
+
+.control-label {
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: var(--card-ink);
+}
+
+.map-select {
+ padding: 0.4rem 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: var(--surface-radius-sm);
+ background: var(--card-bg-soft);
+ color: var(--card-ink);
+ font-size: 1rem;
+ cursor: pointer;
+}
+
+.map-controls-toggle-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.5rem;
+ margin-top: 0.55rem;
+}
+
+.map-layer-menu {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-sm);
+ background: var(--card-bg-soft);
+ padding: 0.45rem 0.5rem 0.55rem;
+}
+
+.map-layer-menu-toggle {
+ width: 100%;
+ text-align: left;
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.45rem 0.6rem;
+ cursor: pointer;
+ user-select: none;
+ font-family: var(--mono);
+ font-size: 0.95rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--card-ink);
+ transition: background 0.15s;
+}
+
+.map-layer-menu-toggle:hover {
+ background: var(--paper-dark);
+}
+
+.map-layer-menu-title {
+ margin-top: 0.55rem;
+ font-family: var(--mono);
+ font-size: 0.88rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--card-sub);
+}
+
+.map-layer-menu-panel {
+ margin-top: 0.45rem;
+}
+
+.toggle-control {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0.6rem;
+ cursor: pointer;
+ border-radius: var(--surface-radius-sm);
+ transition: background 0.15s;
+ font-family: var(--mono);
+}
+
+.toggle-control:hover {
+ background: var(--card-bg-soft);
+}
+
+.toggle-control input[type="checkbox"] {
+ cursor: pointer;
+}
+
+.toggle-label {
+ font-size: 1rem;
+ color: var(--card-ink);
+ font-weight: 700;
+ letter-spacing: 0.015em;
+}
+
+.unified-map-container {
+ height: calc(100vh - 490px);
+ min-height: 420px;
+ border: var(--surface-border);
+ border-radius: 10px;
overflow: hidden;
- background: var(--surface);
- box-shadow: var(--shadow-md);
}
-button, input, select { font-family: var(--ui); }
-
-.search-row input,
-.search-row button,
-.maps-control select,
-.settings-btn {
- font-size: 16px; /* prevent iOS focus zoom */
+.unified-map-shell {
+ position: relative;
}
+.map-overlay-readouts {
+ position: absolute;
+ left: 0.7rem;
+ right: 0.7rem;
+ bottom: 0.7rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ z-index: 650;
+ pointer-events: none;
+}
+
+.map-readout-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.3rem 0.62rem;
+ border-radius: 999px;
+ background: rgba(20, 18, 14, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.24);
+ color: #fff;
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5);
+ box-shadow: 0 3px 12px rgba(0, 0, 0, 0.34);
+}
+
+.map-timeline-wrap {
+ padding: 0.6rem 0.75rem;
+ background: var(--paper-darker);
+ border-top: 1px solid var(--border);
+ border-radius: 0 0 10px 10px;
+}
+
+.map-data-panel {
+ background: var(--card-bg-soft);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-sm);
+ padding: 1rem 1.1rem;
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+}
+
+.panel-location {
+ font-weight: 700;
+ color: var(--card-ink);
+ font-size: 1.08rem;
+}
+
+.panel-data {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.85rem;
+}
+
+.data-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.leaflet-tooltip.map-label {
+ background: rgba(20, 18, 14, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ color: #fff;
+ border-radius: 999px;
+ padding: 0.18rem 0.52rem;
+ font-family: var(--mono);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.55);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
+}
+
+.leaflet-tooltip.map-label:before {
+ border-top-color: rgba(20, 18, 14, 0.9);
+}
+
+.data-label {
+ font-size: 0.78rem;
+ color: var(--card-sub);
+ text-transform: uppercase;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+}
+
+.data-item span:last-child {
+ font-size: 1.02rem;
+ color: var(--card-ink);
+ font-weight: 700;
+}
+
+
+
+.tab {
+ background: transparent;
+ border: none;
+ border-right: 1px solid var(--border);
+ padding: 0.4rem 1rem;
+ font-family: var(--mono);
+ font-size: 0.95rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-mid);
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+
+.tab:last-child { border-right: none; }
+.tab:hover { background: var(--paper-dark); color: var(--ink); }
+.tab.active { background: var(--ink); color: var(--paper); }
+
+/* ── Search ── */
+.search-wrap {
+ position: relative;
+ margin-bottom: 2rem;
+ background: var(--card-bg);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ padding: 0.55rem;
+ box-shadow: var(--surface-shadow);
+}
+
+.search-row { display: flex; }
+
.search-row input {
flex: 1;
min-width: 0;
- min-height: 46px;
+ background: rgba(0, 0, 0, 0.18);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-right: none;
+ padding: 0.6rem 1rem;
+ font-family: var(--mono);
+ font-size: 0.95rem;
+ color: var(--card-ink);
+ outline: none;
+ border-radius: 0;
+ transition: background 0.15s;
+}
+
+.search-row input::placeholder { color: var(--card-sub); }
+.search-row input:focus { background: rgba(0, 0, 0, 0.25); }
+
+.search-row button {
+ background: rgba(0, 0, 0, 0.24);
+ color: var(--card-ink);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ padding: 0.6rem 1.1rem;
+ font-family: var(--mono);
+ font-size: 0.95rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ cursor: pointer;
+ border-radius: 0;
+ transition: opacity 0.15s;
+ white-space: nowrap;
+}
+
+.search-row button:hover { opacity: 0.72; }
+
+#locate-btn {
+ background: rgba(0, 0, 0, 0.22) !important;
+ color: var(--card-ink) !important;
+ border-left: none !important;
+ font-size: 1.2rem !important;
+ letter-spacing: 0 !important;
+ padding: 0.6rem 0.9rem !important;
+}
+
+#units-btn {
+ background: rgba(0, 0, 0, 0.22) !important;
+ color: var(--card-ink) !important;
+ border-left: none !important;
+ min-width: 76px;
+}
+
+.saved-wrap { margin-top: 0.6rem; }
+.saved-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.62rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-light);
+ margin-bottom: 0.35rem;
+}
+
+.saved-locations {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ font-size: 0.72rem;
+ color: var(--ink-mid);
+}
+
+/* ── Toggle Switch ── */
+.switch-toggle {
+ display: inline-flex;
+ align-items: center;
+ position: relative;
+ width: 50px;
+ height: 28px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.switch-toggle input {
+ display: none;
+}
+
+.toggle-slider {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(255, 255, 255, 0.1);
+ border: var(--surface-border);
+ border-radius: 14px;
+ transition: all 0.3s ease;
+ display: flex;
+ align-items: center;
+}
+
+.toggle-slider::before {
+ content: '';
+ position: absolute;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background-color: var(--ink-light);
+ left: 2px;
+ transition: all 0.3s ease;
+}
+
+.switch-toggle input:checked + .toggle-slider {
+ background-color: var(--accent);
+ border-color: var(--accent);
+}
+
+.switch-toggle input:checked + .toggle-slider::before {
+ left: 26px;
+ background-color: #fff;
+}
+
+.switch-toggle input:disabled + .toggle-slider {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.auto-detect-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-bottom: 0.45rem;
+ font-size: 0.68rem;
+ color: var(--ink-mid);
+ cursor: pointer;
+ user-select: none;
+}
+
+.auto-detect-toggle input {
+ accent-color: var(--accent);
+}
+
+.toggle-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-bottom: 0.45rem;
+ font-size: 0.68rem;
+ color: var(--ink-mid);
+ cursor: pointer;
+ user-select: none;
+}
+
+.toggle-label input {
+ accent-color: var(--accent);
+}
+
+.toggle-label span {
+ min-width: 24px;
+}
+
+.saved-item {
+ display: inline-flex;
+ border: 1px solid var(--border);
+ background: var(--paper-dark);
+}
+
+.saved-chip,
+.saved-remove {
border: none;
background: transparent;
color: var(--ink);
- padding: 0 0.75rem;
-}
-
-.search-row button {
- flex: 0 0 auto;
- min-height: 46px;
- border: none;
- border-left: 1px solid var(--line);
+ padding: 0.35rem 0.45rem;
+ font-family: var(--mono);
cursor: pointer;
- padding: 0 0.65rem;
}
-#search-btn {
- background: linear-gradient(180deg, #15445f 0%, #11344c 100%);
- color: #f2f8fc;
- min-width: 52px;
- width: 52px;
- font-size: 0;
- letter-spacing: 0;
- text-transform: none;
- padding: 0;
-}
-
-#search-btn::before {
- content: '🔎';
- font-size: 1rem;
- line-height: 1;
-}
-
-#locate-btn,
-#units-btn {
- background: var(--surface) !important;
- color: var(--ink) !important;
- min-width: 48px;
-}
-
-#locate-btn {
- font-size: 1.05rem !important;
-}
-
-@media (max-width: 390px) {
- #search-btn,
- #locate-btn,
- #units-btn {
- min-width: 46px;
- width: 46px;
- padding: 0 0.5rem;
- }
+.saved-remove {
+ border-left: 1px solid var(--border);
+ color: var(--ink-light);
}
+/* Suggestions */
.suggestions {
+ position: absolute;
+ top: 100%;
+ left: 0; right: 0;
+ background: var(--paper-darker);
+ border: 1px solid var(--border);
+ border-top: none;
+ z-index: 200;
list-style: none;
- margin-top: 0.3rem;
- border-radius: var(--radius-lg);
- overflow: hidden;
- border: 1px solid var(--line);
- background: var(--surface);
- box-shadow: var(--shadow-lg);
}
.suggestions li {
- padding: 0.72rem 0.8rem;
- border-bottom: 1px solid var(--line);
- font-size: 0.88rem;
+ padding: 0.6rem 1rem;
cursor: pointer;
+ font-size: 0.82rem;
+ border-bottom: 0.5px solid var(--border);
+ transition: background 0.1s;
}
.suggestions li:last-child { border-bottom: none; }
-.suggestions li:hover { background: rgba(29, 135, 191, 0.09); }
-.sug-country { color: var(--ink-muted); font-size: 0.8rem; margin-left: 0.35rem; }
+.suggestions li:hover { background: var(--paper-dark); }
+.sug-country { color: var(--ink-light); margin-left: 0.5rem; font-size: 0.75rem; }
-.error {
- margin-top: 0.35rem;
- color: var(--danger);
- font-size: 0.84rem;
-}
-
-/* Generic cards */
-.tab-panel {
- width: 100%;
- min-height: calc(100dvh - 11.6rem);
-}
-
-.loc-header,
-.radar-embed-wrap,
-.radar-full-wrap,
-.maps-wrap,
-.alerts-wrap,
-.panel-note,
-.settings-wrap,
-.daily-list,
-.hourly-scroll,
-.stats-bar,
-.astro-grid,
-.aq-grid {
- border: 1px solid var(--line);
- border-radius: var(--radius-xl);
- background: var(--surface-soft);
- box-shadow: var(--shadow-md);
-}
+/* ── Error / Loading ── */
+.error { font-size: 0.78rem; color: var(--accent); margin-top: 0.5rem; }
.loading {
display: flex;
align-items: center;
- gap: 0.65rem;
- color: var(--ink-soft);
- font-size: 0.88rem;
- padding: 1rem 0.35rem;
+ justify-content: center;
+ min-height: calc(100vh - 200px);
+ padding: 3rem;
}
-.loading-dot {
- width: 11px;
- height: 11px;
- border-radius: 50%;
- background: var(--brand);
- animation: pulse 1.1s ease-in-out infinite;
-}
-
-@keyframes pulse {
- 0%, 100% { opacity: 0.4; transform: scale(0.9); }
- 50% { opacity: 1; transform: scale(1.08); }
-}
-
-.loc-header {
- padding: 0.9rem;
- margin-bottom: 0.75rem;
+.loading-container {
display: flex;
flex-direction: column;
- gap: 0.4rem;
+ align-items: center;
+ gap: 2rem;
+ text-align: center;
}
-.loc-name {
- font-family: var(--display);
+.loading-icon {
+ font-size: 6rem;
+ animation: float 2s ease-in-out infinite;
+}
+
+.loading-text {
font-size: 1.5rem;
- line-height: 1.1;
+ font-weight: 500;
+ color: var(--ink);
+ letter-spacing: 0.05em;
}
-.loc-sub,
-.loc-time {
- font-size: 0.8rem;
- color: var(--ink-muted);
-}
-
-.loc-actions {
+.loading-dots {
display: flex;
- flex-direction: column;
- align-items: flex-start;
+ gap: 0.5rem;
+ justify-content: center;
+}
+
+.loading-dots span {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--accent);
+ animation: bounce 1.4s ease-in-out infinite;
+}
+
+.loading-dots span:nth-child(1) { animation-delay: 0s; }
+.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
+.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-20px); } }
+@keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.4; } 50% { transform: translateY(-15px); opacity: 1; } }
+
+.hidden { display: none !important; }
+
+/* ── Tab panels ── */
+.tab-panel { width: 100%; }
+
+/* ── Learn tab ── */
+.learn-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: var(--card-bg);
+ padding: 1rem;
+}
+
+.learn-title {
+ font-family: var(--serif);
+ font-size: 1.35rem;
+ color: var(--card-ink);
+ margin-bottom: 0.35rem;
+}
+
+.learn-intro {
+ color: var(--card-sub);
+ margin-bottom: 0.95rem;
+}
+
+.learn-toc {
+ border: var(--surface-border);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ padding: 0.6rem;
+ margin-bottom: 0.9rem;
+}
+
+.learn-toc-title {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--card-sub);
+ margin-bottom: 0.4rem;
+}
+
+.learn-toc-links {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.32rem;
}
-.link-btn {
- border: none;
- background: none;
- color: var(--info);
- cursor: pointer;
- font-size: 0.84rem;
-}
-
-.section-head {
- display: flex;
- flex-direction: column;
- gap: 0.22rem;
- margin: 0.35rem 0.45rem 0.5rem;
-}
-
-.section-label {
- font-size: 0.74rem;
- color: var(--ink-soft);
- text-transform: uppercase;
- letter-spacing: 0.08em;
+.learn-toc-links a {
+ border: var(--surface-border);
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.16);
+ color: var(--card-ink);
+ text-decoration: none;
+ font-size: 0.72rem;
font-weight: 700;
+ padding: 0.24rem 0.42rem;
+ text-align: center;
+ line-height: 1.2;
}
-.section-note {
- color: var(--ink-muted);
+.learn-toc-links a:hover {
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.learn-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.learn-card {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-sm);
+ background: var(--card-bg-soft);
+ padding: 0.75rem;
+}
+
+.learn-card h4 {
+ font-size: 0.93rem;
+ color: var(--card-ink);
+ margin-bottom: 0.3rem;
+}
+
+.learn-card p {
+ font-size: 0.84rem;
+ color: var(--card-sub);
+ line-height: 1.45;
+}
+
+.learn-section {
+ margin-top: 1rem;
+ border-top: var(--surface-border);
+ padding-top: 0.9rem;
+}
+
+.learn-section-title {
+ font-size: 1rem;
+ color: var(--card-ink);
+ margin-bottom: 0.25rem;
+}
+
+.learn-section-intro {
+ font-size: 0.82rem;
+ color: var(--card-sub);
+ margin-bottom: 0.6rem;
+}
+
+.learn-range-list {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.learn-range-item {
+ display: grid;
+ grid-template-columns: 18px 108px minmax(180px, 1fr) 1.5fr;
+ align-items: center;
+ gap: 0.55rem;
+ border: var(--surface-border);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ padding: 0.5rem 0.6rem;
+}
+
+.range-swatch {
+ width: 12px;
+ height: 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.4);
+}
+
+.range-value {
font-size: 0.79rem;
-}
-
-.section-actions {
- display: inline-flex;
- flex-wrap: wrap;
- gap: 0.62rem;
-}
-
-/* Current */
-.current-grid {
- display: grid;
- gap: 0.6rem;
- margin-bottom: 0.75rem;
-}
-
-.current-grid > * {
- border-radius: var(--radius-lg);
- border: 1px solid var(--line);
- background: var(--surface);
- padding: 1rem 0.85rem;
- box-shadow: var(--shadow-md);
-}
-
-.temp-big {
- font-family: var(--display);
- font-size: 3.75rem;
- line-height: 0.92;
- letter-spacing: -1.5px;
-}
-
-.deg {
- font-size: 1.35rem;
- color: var(--ink-soft);
- vertical-align: super;
-}
-
-.feels,
-.hi-lo,
-.cond-sub {
- font-size: 0.84rem;
- color: var(--ink-soft);
-}
-
-.current-cond { display: flex; flex-direction: column; justify-content: center; }
-.cond-icon { font-size: 2.2rem; margin-bottom: 0.15rem; }
-.cond-desc { font-size: 1.08rem; font-weight: 700; }
-
-/* Stat blocks */
-.stats-bar,
-.astro-grid,
-.aq-grid {
- display: grid;
- gap: 0.52rem;
- border: none;
- background: transparent;
- box-shadow: none;
- margin-bottom: 0.78rem;
-}
-
-.stats-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-
-.stat,
-.astro-card,
-.aq-card,
-.maps-card,
-.settings-row,
-.alert-item {
- border: 1px solid var(--line);
- border-radius: var(--radius-md);
- background: var(--surface);
- box-shadow: var(--shadow-md);
-}
-
-.stat { padding: 0.78rem 0.58rem; text-align: center; }
-
-.stat-label {
- font-size: 0.64rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--ink-muted);
- margin-bottom: 0.2rem;
+ color: var(--card-ink);
font-weight: 700;
}
-.stat-val { font-size: 1.02rem; font-weight: 700; }
-.unit { font-size: 0.72rem; color: var(--ink-soft); }
-
-.astro-card,
-.aq-card,
-.maps-card { padding: 0.74rem 0.7rem; }
-.astro-val,
-.aq-val,
-.maps-value { font-size: 0.98rem; font-weight: 700; }
-
-/* Radar & maps */
-.radar-embed-wrap,
-.radar-full-wrap,
-.maps-wrap,
-.alerts-wrap,
-.settings-wrap {
- padding: 0.7rem;
- margin-bottom: 0.85rem;
+.range-label {
+ font-size: 0.8rem;
+ color: var(--card-ink);
+ font-weight: 600;
}
-.radar-map,
-.map-overview {
- width: 100%;
- height: 235px;
- border-radius: var(--radius-md);
- border: 1px solid var(--line);
+.range-note {
+ font-size: 0.77rem;
+ color: var(--card-sub);
+}
+
+.learn-table {
+ display: grid;
+ border: var(--surface-border);
+ border-radius: 10px;
overflow: hidden;
}
-.radar-map-full {
- height: calc(100dvh - 20rem);
- min-height: 310px;
+.learn-table-row {
+ display: grid;
+ grid-template-columns: 120px 1.2fr 1.6fr;
+ gap: 0.5rem;
+ padding: 0.5rem 0.6rem;
+ background: var(--card-bg-soft);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
+.learn-table-row:last-child {
+ border-bottom: none;
+}
+
+.learn-table-row span {
+ font-size: 0.78rem;
+ color: var(--card-sub);
+}
+
+.learn-table-head {
+ background: rgba(0, 0, 0, 0.18);
+}
+
+.learn-table-head span {
+ color: var(--card-ink);
+ font-size: 0.74rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.learn-table-row span:first-child {
+ color: var(--card-ink);
+ font-weight: 700;
+}
+
+.aqi-good { background: #00a650; }
+.aqi-moderate { background: #ffd000; }
+.aqi-sensitive { background: #ff8c00; }
+.aqi-unhealthy { background: #e53935; }
+.aqi-very-unhealthy { background: #8e24aa; }
+.aqi-hazardous { background: #7e0023; }
+
+.wind-calm { background: #4fc3f7; }
+.wind-breezy { background: #43a047; }
+.wind-windy { background: #fdd835; }
+.wind-strong { background: #fb8c00; }
+.wind-severe { background: #e53935; }
+
+.radar-light { background: #1e88e5; }
+.radar-moderate { background: #fdd835; }
+.radar-heavy { background: #f4511e; }
+.radar-extreme { background: #8e24aa; }
+
+.comfort-dry { background: #4fc3f7; }
+.comfort-pleasant { background: #66bb6a; }
+.comfort-sticky { background: #fdd835; }
+.comfort-oppressive { background: #fb8c00; }
+.comfort-tropical { background: #e53935; }
+
+.vis-good { background: #43a047; }
+.vis-fair { background: #fdd835; }
+.vis-poor { background: #fb8c00; }
+.vis-danger { background: #e53935; }
+
+.uv-low { background: #43a047; }
+.uv-moderate { background: #fdd835; }
+.uv-high { background: #fb8c00; }
+.uv-very-high { background: #e53935; }
+.uv-extreme { background: #8e24aa; }
+
+.cloud-clear { background: #64b5f6; }
+.cloud-partly { background: #90caf9; }
+.cloud-mostly { background: #78909c; }
+.cloud-overcast { background: #546e7a; }
+
+.learn-list {
+ margin: 0;
+ padding-left: 1.1rem;
+ display: grid;
+ gap: 0.35rem;
+ color: var(--card-sub);
+ font-size: 0.84rem;
+ line-height: 1.45;
+}
+
+.learn-mini-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.55rem;
+}
+
+.learn-mini-card {
+ border: var(--surface-border);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ padding: 0.55rem 0.6rem;
+}
+
+.learn-mini-card h5 {
+ margin: 0 0 0.25rem;
+ font-size: 0.8rem;
+ color: var(--card-ink);
+}
+
+.learn-mini-card p {
+ margin: 0;
+ font-size: 0.78rem;
+ color: var(--card-sub);
+ line-height: 1.4;
+}
+
+.learn-faq {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.learn-faq-item {
+ border: var(--surface-border);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ padding: 0.5rem 0.6rem;
+}
+
+.learn-faq-item summary {
+ cursor: pointer;
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--card-ink);
+}
+
+.learn-faq-item p {
+ margin: 0.45rem 0 0;
+ font-size: 0.79rem;
+ color: var(--card-sub);
+ line-height: 1.45;
+}
+
+.learn-checklist {
+ margin: 0;
+ padding-left: 0;
+ list-style: none;
+ display: grid;
+ gap: 0.4rem;
+}
+
+.learn-checklist li {
+ border: var(--surface-border);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ padding: 0.45rem 0.6rem 0.45rem 1.95rem;
+ position: relative;
+ font-size: 0.8rem;
+ color: var(--card-sub);
+ line-height: 1.4;
+}
+
+.learn-checklist li::before {
+ content: '✓';
+ position: absolute;
+ left: 0.65rem;
+ top: 0.45rem;
+ color: var(--accent);
+ font-weight: 800;
+}
+
+.learn-glossary-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.5rem;
+}
+
+.glossary-item {
+ border: var(--surface-border);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ padding: 0.55rem 0.6rem;
+}
+
+.glossary-item h5 {
+ margin: 0 0 0.2rem;
+ font-size: 0.8rem;
+ color: var(--card-ink);
+}
+
+.glossary-item p {
+ margin: 0;
+ font-size: 0.77rem;
+ color: var(--card-sub);
+ line-height: 1.4;
+}
+
+.learn-back-top {
+ position: fixed;
+ right: 1rem;
+ bottom: calc(0.9rem + env(safe-area-inset-bottom, 0px));
+ z-index: 460;
+ border: 1px solid rgba(255, 255, 255, 0.25);
+ border-radius: 999px;
+ background: rgba(20, 18, 14, 0.94);
+ color: #fff;
+ text-decoration: none;
+ font-size: 0.78rem;
+ font-weight: 700;
+ padding: 0.42rem 0.72rem;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
+}
+
+.learn-back-top:hover {
+ background: rgba(20, 18, 14, 1);
+}
+
+/* ── Location header ── */
+.loc-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 1.5rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-lg);
+ background: var(--card-bg-soft);
+ padding: 0.9rem 1rem;
+}
+
+.loc-name { font-family: var(--serif); font-size: 1.6rem; font-weight: 400; line-height: 1.1; }
+.loc-name { color: var(--card-ink); }
+.loc-sub { font-size: 0.8rem; color: var(--card-sub); letter-spacing: 0.06em; margin-top: 0.2rem; text-transform: uppercase; }
+.loc-time { font-size: 0.8rem; color: var(--card-sub); text-align: right; letter-spacing: 0.04em; line-height: 1.5; }
+.loc-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 0.35rem; }
+
+/* ── HERO ── */
+.hero {
+ display: grid;
+ grid-template-columns: 1fr 1.5fr 1fr;
+ align-items: center;
+ gap: 1rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-lg);
+ background: var(--card-bg);
+ color: var(--card-ink);
+ padding: 1.4rem 1rem;
+ margin-bottom: 1.8rem;
+}
+
+.hero-left {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.4rem;
+}
+
+.hero-icon { font-size: 3.5rem; line-height: 1; }
+.hero-desc { font-size: 0.9rem; font-weight: 500; }
+.hero-sub { font-size: 0.82rem; color: var(--card-sub); margin-top: 0.15rem; }
+
+.hero-center { text-align: center; }
+
+.hero-share-btn {
+ margin-top: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.2);
+ color: var(--card-ink);
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ padding: 0.3rem 0.62rem;
+ cursor: pointer;
+}
+
+.hero-share-btn:hover {
+ background: rgba(0, 0, 0, 0.34);
+}
+
+.hero-share-status {
+ margin-top: 0.25rem;
+ min-height: 1rem;
+ font-size: 0.7rem;
+ color: var(--card-sub);
+}
+
+.hero-temp {
+ font-family: var(--serif);
+ font-size: 6.5rem;
+ font-weight: 400;
+ line-height: 1;
+ letter-spacing: -4px;
+}
+
+.deg { font-family: var(--mono); font-size: 2rem; color: var(--card-sub); vertical-align: super; letter-spacing: 0; display: inline; }
+.feels { font-size: 0.86rem; color: var(--card-sub); margin-top: 0.5rem; }
+
+.hero-right { display: flex; justify-content: flex-end; }
+
+.hero-hilo {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.6rem;
+ background: rgba(0, 0, 0, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ padding: 1rem 1.4rem;
+ min-width: 80px;
+}
+
+.hilo-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ line-height: 1.2;
+ font-size: 1.3rem;
+ font-weight: 500;
+}
+
+.hilo-arrow { font-size: 0.75rem; color: var(--ink-light); }
+.hilo-label { font-size: 0.55rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--card-sub); margin-top: 0.15rem; }
+.hilo-divider { width: 100%; height: 1px; background: rgba(255, 255, 255, 0.12); }
+
+/* ── Sun & Moon Timeline ── */
+.sun-section {
+ border: var(--surface-border);
+ background: var(--card-bg);
+ color: var(--card-ink);
+ border-radius: var(--surface-radius-md);
+ padding: 1rem 1.1rem;
+ margin-bottom: 2rem;
+}
+
+.sun-timeline-wrap {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.65rem;
+}
+
+.sun-endpoint {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.2rem;
+ flex-shrink: 0;
+}
+
+.sun-ep-icon { font-size: 1.6rem; line-height: 1; }
+.sun-ep-time { font-size: 0.8rem; font-weight: 600; color: var(--card-sub); white-space: nowrap; }
+
+.sun-track-wrap { flex: 1; position: relative; }
+
+.sun-track {
+ height: 6px;
+ background: rgba(255, 255, 255, 0.14);
+ border-radius: 3px;
+ position: relative;
+ overflow: visible;
+}
+
+.sun-progress {
+ height: 100%;
+ background: linear-gradient(90deg, #fb923c, #fbbf24);
+ border-radius: 3px;
+ transition: width 0.6s ease;
+}
+
+.sun-dot {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 1.2rem;
+ pointer-events: none;
+ transition: left 0.6s ease;
+}
+
+.moon-row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--card-sub);
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
+ padding-top: 0.6rem;
+}
+
+.moon-emoji { font-size: 1.2rem; }
+.daylight-info { margin-left: auto; font-size: 0.78rem; font-weight: 500; color: var(--card-sub); }
+
+/* ── Card Group ── */
+.card-group {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
+ gap: 1px;
+ background: rgba(255, 255, 255, 0.08);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ overflow: hidden;
+ margin-bottom: 2rem;
+}
+
+.stat-card {
+ background: var(--card-bg);
+ padding: 1rem 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.stat-card .stat-label {
+ font-size: 0.68rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-weight: 700;
+ color: var(--card-sub);
+}
+
+.stat-card .stat-val {
+ font-size: 1.65rem;
+ font-weight: 600;
+ line-height: 1.2;
+ color: var(--card-ink);
+}
+
+.stat-unit { font-size: 1rem; font-weight: 600; color: var(--card-sub); margin-left: 2px; }
+.stat-sub { font-size: 0.8rem; font-weight: 500; color: var(--card-sub); }
+
+/* ── Details Drawer ── */
+.details-drawer {
+ border: var(--surface-border);
+ margin-bottom: 2rem;
+ background: var(--card-bg);
+ border-radius: var(--surface-radius-md);
+ overflow: hidden;
+}
+
+.drawer-summary {
+ list-style: none;
+ font-size: 0.68rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--card-sub);
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ user-select: none;
+}
+
+.drawer-summary::-webkit-details-marker { display: none; }
+.drawer-summary:focus { outline: none; }
+
+.drawer-chevron {
+ font-size: 1.1rem;
+ transition: transform 0.2s;
+ display: inline-block;
+}
+
+details[open] .drawer-chevron { transform: rotate(90deg); }
+
+.drawer-body { border-top: 1px solid rgba(255, 255, 255, 0.1); }
+.drawer-card-group { margin-bottom: 0; border: none; }
+
+/* ── What to Bring ── */
+.recs-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+}
+
+.rec-item {
+ background: var(--card-bg);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ padding: 0.85rem 0.6rem;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.rec-icon { font-size: 2rem; line-height: 1; }
+.rec-label { font-size: 0.72rem; font-weight: 600; color: var(--card-sub); letter-spacing: 0.04em; line-height: 1.3; }
+
+.alerts-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ padding: 0.75rem;
+ margin-bottom: 1.2rem;
+ background: var(--card-bg);
+}
+
+.alerts-actions {
+ display: inline-flex;
+ gap: 0.45rem;
+ align-items: center;
+}
+
+.alerts-share-status {
+ margin: 0.3rem 0 0.5rem;
+ font-size: 0.74rem;
+ color: var(--card-sub);
+}
+
+.alerts-list {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.alerts-group {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ padding: 0.45rem;
+ background: rgba(0, 0, 0, 0.08);
+}
+
+.alerts-group-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.35rem;
+ padding: 0.1rem 0.2rem;
+}
+
+.alerts-group-title {
+ font-size: 0.76rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--card-ink);
+}
+
+.alerts-group-hint {
+ font-size: 0.7rem;
+ color: var(--card-sub);
+}
+
+.alerts-group-extreme .alerts-group-title,
+.alerts-group-severe .alerts-group-title {
+ color: #f28b82;
+}
+
+.alerts-group-moderate .alerts-group-title {
+ color: #ffd166;
+}
+
+.alert-item {
+ display: grid;
+ gap: 0.3rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ padding: 0.58rem;
+ background: var(--card-bg-soft);
+ font-size: 0.74rem;
+}
+
+.alert-top {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.alert-severity {
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ padding: 0.06rem 0.45rem;
+ font-size: 0.65rem;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ font-weight: 700;
+}
+
+.severity-extreme,
+.severity-severe {
+ background: rgba(229, 57, 53, 0.26);
+ color: #ffd3d1;
+}
+
+.severity-moderate {
+ background: rgba(255, 209, 102, 0.25);
+ color: #ffe4a3;
+}
+
+.severity-minor,
+.severity-unknown {
+ background: rgba(120, 144, 156, 0.25);
+ color: #e3eef5;
+}
+
+.alert-timing {
+ font-size: 0.7rem;
+ color: var(--card-sub);
+}
+
+.alert-message {
+ font-size: 0.74rem;
+ color: var(--card-sub);
+ line-height: 1.35;
+}
+
+.alert-action {
+ border-top: 1px dashed rgba(255, 255, 255, 0.14);
+ padding-top: 0.3rem;
+ font-size: 0.73rem;
+ color: var(--card-ink);
+ font-weight: 600;
+}
+
+.panel-note {
+ font-size: 0.72rem;
+ color: var(--ink-light);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ padding: 0.75rem;
+ background: var(--card-bg);
+}
+
+.settings-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: var(--card-bg);
+ padding: 0.9rem;
+ display: grid;
+ gap: 1rem;
+}
+
+.settings-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ padding: 0.65rem 0.75rem;
+}
+
+.settings-label {
+ font-size: 0.72rem;
+ color: var(--ink-mid);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.settings-value {
+ font-size: 0.8rem;
+ color: var(--ink);
+ text-align: right;
+}
+
+.settings-btn {
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 10px;
+ background: var(--card-bg-soft);
+ color: var(--ink);
+ padding: 0.45rem 0.7rem;
+ font-family: var(--mono);
+ font-size: 0.72rem;
+ cursor: pointer;
+}
+
+.settings-btn:hover { background: var(--card-bg); }
+
+.maps-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: var(--card-bg);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.maps-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+}
+
+.maps-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--card-ink);
+}
+
+.maps-quick-toggles {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.overlay-toggle {
+ padding: 0.5rem 1rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-sm);
+ background: var(--card-bg-soft);
+ color: var(--card-sub);
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.overlay-toggle:hover {
+ background: var(--card-bg);
+ border-color: var(--accent);
+}
+
+.overlay-toggle.active {
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+
+.map-overview {
+ height: calc(100vh - 470px);
+ min-height: 420px;
+ border: var(--surface-border);
+ border-radius: 10px;
+ box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+.maps-legend {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ padding: 0.75rem;
+ background: rgba(255, 255, 255, 0.04);
+ border: var(--surface-border);
+ border-radius: 8px;
+}
+
+.legend-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.legend-title {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--card-ink);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ font-size: 0.75rem;
+ color: var(--card-sub);
+}
+
+.legend-item svg {
+ flex-shrink: 0;
+}
+
+.maps-data {
+ display: grid;
+ grid-template-columns: 1fr 1.2fr;
+ gap: 1rem;
+}
+
+.maps-card {
+ background: var(--card-bg-soft);
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-sm);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.card-title {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--card-sub);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.maps-value {
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--card-ink);
+}
+
+.wind-stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 0.75rem;
+}
+
+.wind-stat {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.wind-stat .stat-label {
+ font-size: 0.7rem;
+ color: var(--card-sub);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.wind-stat .stat-value {
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; }
+
+/* ── Section heads ── */
+.section-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 0.6rem;
+}
+
+.section-label { font-size: 0.88rem; font-weight: 800; letter-spacing: 0.14em; color: var(--ink); text-transform: uppercase; }
+.section-note { font-size: 0.72rem; color: var(--card-sub); font-style: italic; }
+
+.link-btn {
+ background: none;
+ border: none;
+ font-family: var(--mono);
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--cool);
+ cursor: pointer;
+ padding: 0.35rem 0.65rem;
+ font-style: italic;
+}
+
+.link-btn:hover { text-decoration: underline; }
+
+/* ── Radar maps ── */
+.radar-embed-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ overflow: hidden;
+ margin-bottom: 2rem;
+ background: #1a1f2e;
+}
+
+.radar-map {
+ width: 100%;
+ height: 320px;
+}
+
+.radar-map-full {
+ height: calc(100vh - 280px);
+ min-height: 420px;
+}
+
+.radar-full-wrap {
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: #1a1f2e;
+ margin-bottom: 0.5rem;
+ overflow: hidden;
+}
+
+/* ── Radar controls ── */
.radar-controls {
display: flex;
- flex-wrap: wrap;
align-items: center;
- gap: 0.45rem;
- margin-top: 0.62rem;
+ gap: 0.75rem;
+ padding: 0.6rem 0.75rem;
+ background: var(--paper-darker);
+ border-top: 1px solid var(--border);
}
+.radar-controls-full { padding: 0.75rem 1rem; }
+
.radar-btn {
- width: 46px;
- height: 46px;
+ background: var(--ink);
+ color: var(--paper);
border: none;
- border-radius: 12px;
- background: #0f3248;
- color: #f5fbfe;
+ width: 28px; height: 28px;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 0.7rem;
cursor: pointer;
- font-size: 0.86rem;
+ flex-shrink: 0;
+ transition: opacity 0.15s;
}
-.layer-btn { background: var(--brand); }
+.radar-btn:hover { opacity: 0.72; }
+.layer-btn { background: var(--accent); }
.radar-timeline {
- order: 3;
- flex-basis: 100%;
- height: 32px;
+ flex: 1;
display: flex;
gap: 2px;
+ align-items: center;
+ height: 28px;
}
.tl-tick {
flex: 1;
- border-radius: 7px;
- border: 1px solid var(--line);
- background: rgba(255, 255, 255, 0.52);
+ height: 100%;
+ background: var(--paper-dark);
+ border: 1px solid var(--border);
cursor: pointer;
+ transition: background 0.1s;
+ position: relative;
}
-.tl-tick.active { background: var(--info); border-color: var(--info); }
-.tl-tick.forecast { opacity: 0.6; }
+.tl-tick:hover { background: var(--paper); }
+.tl-tick.active { background: var(--cool); border-color: var(--cool); }
+.tl-tick.forecast { opacity: 0.55; }
+
+.radar-ts {
+ font-size: 0.88rem;
+ color: var(--ink);
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ min-width: 80px;
+ text-align: right;
+}
+
+/* Radar legend */
+.radar-legend {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-left: 0.5rem;
+}
+
+.legend-label { font-size: 0.66rem; color: var(--ink-mid); letter-spacing: 0.06em; }
-.radar-ts { font-size: 0.75rem; color: var(--ink-soft); }
-.radar-legend { display: inline-flex; align-items: center; gap: 0.35rem; }
-.legend-label { font-size: 0.64rem; color: var(--ink-muted); }
.legend-bar {
- width: 84px;
- height: 10px;
- border-radius: 999px;
- border: 1px solid var(--line);
- background: linear-gradient(to right, #00d8ff, #00a0e0, #0070d0, #00c800, #ffff00, #ff6000, #ff0000);
+ width: 80px;
+ height: 8px;
+ background: linear-gradient(to right,
+ #00d8ff, #00a0e0, #0070d0,
+ #00c800, #80e000, #ffff00,
+ #ffc000, #ff6000, #ff0000, #c00000
+ );
+ border: 0.5px solid var(--border);
}
-.radar-attribution,
-.footer-note {
- font-size: 0.74rem;
- color: var(--ink-muted);
- margin-bottom: 0.8rem;
-}
-
-/* Hourly */
-.hourly-summary {
- margin: 0.22rem 0.38rem 0.45rem;
- font-size: 0.8rem;
- color: var(--ink-soft);
+.radar-attribution {
+ font-size: 0.62rem;
+ color: var(--ink-light);
+ padding-top: 0.5rem;
}
+/* ── Hourly scroll ── */
.hourly-scroll {
overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- margin-bottom: 0.85rem;
- padding: 0.42rem;
+ margin-bottom: 2rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
}
.hourly-track { display: flex; min-width: max-content; }
.hour-cell {
- min-width: 90px;
- padding: 0.72rem 0.45rem;
+ min-width: 72px;
+ padding: 0.75rem 0.5rem;
text-align: center;
- border-radius: 12px;
- border: 1px solid var(--line);
- background: var(--surface);
- margin-right: 0.3rem;
+ border-right: 0.5px solid var(--border);
+ background: var(--card-bg);
+ transition: background 0.12s;
}
-.hour-cell.now {
- background: linear-gradient(180deg, rgba(29, 135, 191, 0.18) 0%, var(--surface) 100%);
-}
+.hour-cell:last-child { border-right: none; }
+.hour-cell:hover { background: var(--paper-dark); }
+.hour-cell.now { background: var(--paper-darker); }
-.hour-time { font-size: 0.7rem; color: var(--ink-muted); margin-bottom: 0.28rem; }
-.hour-icon { font-size: 1.35rem; margin-bottom: 0.18rem; }
-.hour-temp { font-size: 1rem; font-weight: 700; }
-.hour-feels,
-.hour-precip { font-size: 0.68rem; color: var(--ink-soft); }
+.hour-time { font-size: 0.6rem; color: var(--card-sub); letter-spacing: 0.06em; margin-bottom: 0.35rem; text-transform: uppercase; }
+.hour-icon { font-size: 1.4rem; margin-bottom: 0.3rem; }
+.hour-temp { font-size: 0.88rem; font-weight: 500; color: var(--card-ink); }
+.hour-precip { font-size: 0.64rem; color: #1f5f7e; margin-top: 0.2rem; font-weight: 600; }
-/* Daily */
+/* ── Daily forecast ── */
.daily-list {
- padding: 0.4rem;
- margin-bottom: 0.9rem;
+ border: none;
+ display: grid;
+ gap: 0.55rem;
+ margin-bottom: 2rem;
}
.day-row {
display: grid;
- grid-template-columns: 1fr auto;
- grid-template-areas:
- 'name temps'
- 'desc desc';
- gap: 0.28rem 0.55rem;
- border-radius: 12px;
- border: 1px solid var(--line);
- background: var(--surface);
- padding: 0.72rem;
- margin-bottom: 0.34rem;
-}
-
-.day-row:last-child { margin-bottom: 0; }
-.day-row.today { outline: 1px solid rgba(29, 135, 191, 0.45); }
-.day-name { grid-area: name; font-size: 0.9rem; font-weight: 700; }
-.day-icon { display: none; }
-.day-desc {
- grid-area: desc;
- font-size: 0.78rem;
- color: var(--ink-soft);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.day-temps { grid-area: temps; font-size: 0.88rem; font-weight: 700; }
-.day-lo { color: var(--ink-muted); }
-.day-meta { display: none; }
-
-/* Lists and settings */
-.panel-note {
- padding: 0.75rem;
- font-size: 0.82rem;
- color: var(--ink-soft);
-}
-
-.alerts-list,
-.saved-locations,
-.maps-controls,
-.maps-meta {
- display: grid;
- gap: 0.5rem;
-}
-
-.alert-item {
- padding: 0.72rem;
- display: grid;
- gap: 0.22rem;
- font-size: 0.82rem;
-}
-
-.maps-control {
- display: grid;
- gap: 0.3rem;
- font-size: 0.68rem;
- color: var(--ink-muted);
- text-transform: uppercase;
- letter-spacing: 0.05em;
-}
-
-.maps-control input,
-.maps-control select,
-.settings-btn {
- min-height: 44px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: var(--surface);
- color: var(--ink);
- padding: 0.35rem 0.55rem;
-}
-
-#maps-fullscreen-btn {
- font-size: 0.78rem;
- font-weight: 700;
-}
-
-body.map-fullscreen-lock {
- overflow: hidden;
-}
-
-.maps-wrap.is-fullscreen {
- position: fixed;
- inset: 0;
- z-index: 520;
- margin: 0;
- border-radius: 0;
- border: none;
- background: var(--paper);
- overflow-y: auto;
- padding: calc(0.65rem + env(safe-area-inset-top, 0px)) 0.65rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
-}
-
-.maps-wrap.is-fullscreen .map-overview {
- height: calc(100dvh - 210px);
- min-height: 320px;
-}
-
-.wind-legend {
- display: flex;
+ grid-template-columns: 120px 44px 1fr 80px 60px;
align-items: center;
- flex-wrap: wrap;
- gap: 0.42rem;
- margin-top: 0.52rem;
- font-size: 0.71rem;
- color: var(--ink-muted);
+ gap: 1rem;
+ padding: 0.85rem 1.1rem;
+ border: var(--surface-border);
+ border-radius: var(--surface-radius-md);
+ background: var(--card-bg);
+ transition: background 0.12s;
}
-.wind-legend-scale {
- width: 126px;
- height: 10px;
- border-radius: 999px;
- border: 1px solid var(--line);
- background: linear-gradient(to right, #d2f0ff, #84ccff, #3f9fff, #1879df);
+.day-row:last-child { border-bottom: var(--surface-border); }
+.day-row:hover { background: var(--paper-dark); }
+.day-row.today { background: var(--paper-dark); }
+
+.day-name { font-size: 0.8rem; font-weight: 500; color: var(--card-ink); }
+.day-icon { font-size: 1.5rem; text-align: center; }
+.day-desc { font-size: 0.75rem; color: var(--ink-mid); }
+.day-temps { text-align: right; font-size: 0.82rem; font-weight: 500; white-space: nowrap; color: var(--card-ink); }
+.day-lo { color: var(--ink-mid); }
+.day-meta { text-align: right; font-size: 0.62rem; color: var(--ink-mid); line-height: 1.6; }
+
+/* ── Footer ── */
+.footer-note {
+ font-size: 0.72rem;
+ color: var(--ink-light);
+ letter-spacing: 0.04em;
+ border-top: 0.5px solid var(--border);
+ padding-top: 0.75rem;
}
-.saved-wrap { margin-top: 0.1rem; }
+/* ── PWA: safe area insets (iPhone notch / home bar) ── */
+@supports (padding: env(safe-area-inset-bottom)) {
+ .app {
+ padding-top: max(2rem, calc(1rem + env(safe-area-inset-top)));
+ padding-left: max(1.5rem, calc(1rem + env(safe-area-inset-left)));
+ padding-right: max(1.5rem, calc(1rem + env(safe-area-inset-right)));
+ padding-bottom: max(4rem, calc(2rem + env(safe-area-inset-bottom)));
+ }
+ .radar-map-full {
+ height: calc(100vh - 280px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
+ }
+ .map-overview {
+ height: calc(100vh - 470px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
+ }
+}
-.saved-head {
+/* ── PWA: status bar area when running standalone ── */
+@media (display-mode: standalone) {
+ .masthead { padding-top: max(1rem, calc(0.75rem + env(safe-area-inset-top))); }
+}
+
+/* ── Touch-friendly tap targets (min 44 × 44 px) ── */
+.tab {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.search-row input,
+.search-row button { min-height: 44px; }
+.radar-btn { width: 44px; height: 44px; font-size: 0.8rem; }
+.tl-tick { min-height: 36px; }
+
+/* iOS momentum scrolling */
+.hourly-scroll { -webkit-overflow-scrolling: touch; }
+
+/* ── Leaflet overrides ── */
+.leaflet-container { background: #1a1f2e; }
+.leaflet-tile { transition: opacity 0.24s linear; }
+.leaflet-control-zoom a {
+ background: var(--paper-dark) !important;
+ color: var(--ink) !important;
+ border-color: var(--border) !important;
+}
+
+/* ── Mobile Bottom Navigation ── */
+.bottom-nav {
+ display: none;
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100vw;
+ z-index: 300;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(25, 21, 16, 0.96);
+ backdrop-filter: blur(6px);
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+
+.bottom-tab {
+ flex: 1;
+ min-height: 56px;
+ border: none;
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
+ background: transparent;
+ color: #efe8dc;
+ font-family: var(--mono);
display: flex;
flex-direction: column;
- align-items: flex-start;
- gap: 0.3rem;
- margin-bottom: 0.32rem;
- color: var(--ink-muted);
- text-transform: uppercase;
- letter-spacing: 0.07em;
- font-size: 0.67rem;
-}
-
-.saved-help {
- margin-bottom: 0.38rem;
- font-size: 0.74rem;
- color: var(--ink-muted);
-}
-
-.saved-item {
- display: grid;
- grid-template-columns: 36px 1fr 36px 36px 36px;
- border: 1px solid var(--line);
- border-radius: 12px;
- background: var(--surface);
- overflow: hidden;
-}
-
-.saved-chip,
-.saved-remove,
-.saved-default,
-.saved-move {
- min-height: 44px;
- border: none;
- background: transparent;
- color: var(--ink);
+ align-items: center;
+ justify-content: center;
+ gap: 0.15rem;
cursor: pointer;
}
-.saved-chip {
- padding: 0 0.58rem;
- text-align: left;
- border-left: 1px solid var(--line);
- border-right: 1px solid var(--line);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
+.bottom-tab:last-child { border-right: none; }
+.bottom-icon { font-size: 1rem; line-height: 1; }
+.bottom-label { font-size: 0.58rem; letter-spacing: 0.05em; text-transform: uppercase; }
+
+.bottom-tab.active {
+ background: var(--accent);
+ color: #fff;
}
-.saved-default { color: var(--brand); }
-.saved-move { border-right: 1px solid var(--line); }
-.saved-remove { border-right: 1px solid var(--line); color: var(--ink-muted); }
-.saved-move:disabled { opacity: 0.35; cursor: not-allowed; }
-.saved-meta { display: none; }
-
-.settings-wrap { display: grid; gap: 0.62rem; }
-
-.settings-row {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 0.45rem;
- padding: 0.74rem;
-}
-
-.settings-label {
- font-size: 0.72rem;
- color: var(--ink-muted);
- text-transform: uppercase;
- letter-spacing: 0.05em;
- font-weight: 700;
-}
-
-.settings-value { font-size: 0.88rem; color: var(--ink); }
-
-.auto-detect-toggle,
-.toggle-label {
- display: inline-flex;
- align-items: center;
- gap: 0.48rem;
- color: var(--ink-soft);
- font-size: 0.9rem;
-}
-
-.auto-detect-toggle input,
-.toggle-label input { accent-color: var(--brand); }
-
-button:focus-visible,
-input:focus-visible,
-select:focus-visible {
- outline: 2px solid var(--brand);
- outline-offset: 2px;
-}
-
-/* Leaflet */
-.leaflet-container { background: #1a2430; }
-.leaflet-control-zoom a {
- background: var(--surface) !important;
- color: var(--ink) !important;
- border-color: var(--line) !important;
-}
-
-/* High contrast */
-body.high-contrast {
- --ink: #0a0a0a;
- --ink-soft: #1f1f1f;
- --ink-muted: #3b3b3b;
- --paper: #fff;
- --paper-deep: #f2f2f2;
- --surface: #fff;
- --surface-soft: #fff;
- --line: #111;
- --brand: #a93a00;
- --brand-strong: #8d2f00;
- --info: #005a82;
-}
-
-/* Desktop second */
-@media (min-width: 940px) {
+/* ── Responsive ── */
+@media (max-width: 640px) {
html, body { font-size: 16px; }
-
- .app {
- max-width: 1120px;
- margin: 0 auto;
- padding: 1.35rem 1.3rem calc(6.9rem + env(safe-area-inset-bottom, 0px));
- }
-
+ .app { padding: max(1.2rem, calc(0.8rem + env(safe-area-inset-top, 0px))) 0.9rem max(5.25rem, calc(4.5rem + env(safe-area-inset-bottom, 0px))); }
.masthead {
- position: static;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ gap: 0.35rem;
+ padding: 0.75rem 0.9rem;
+ }
+ .masthead h1 { font-size: 2.2rem; line-height: 1.08; }
+ .tagline { display: none; }
+ .tabs { display: none; }
+ .bottom-nav { display: flex; }
+
+ #tab-radar-full {
+ padding-bottom: max(6.4rem, calc(5.7rem + env(safe-area-inset-bottom, 0px)));
+ }
+
+ .radar-controls-full {
display: grid;
- grid-template-columns: auto 1fr;
+ grid-template-columns: auto auto minmax(0, 1fr);
+ grid-template-areas:
+ 'play layer ts'
+ 'timeline timeline timeline';
+ gap: 0.45rem 0.5rem;
+ padding: 0.55rem 0.65rem;
align-items: center;
- gap: 1rem;
- margin-bottom: 1rem;
- padding: 0;
- border: none;
- box-shadow: none;
- background: transparent;
- backdrop-filter: none;
}
- .masthead h1 { margin: 0; font-size: 2rem; }
+ .radar-controls-full .play-btn { grid-area: play; }
+ .radar-controls-full .layer-btn { grid-area: layer; }
+ .radar-controls-full .radar-ts {
+ grid-area: ts;
+ min-width: 0;
+ text-align: right;
+ font-size: 0.66rem;
+ }
- .tabs {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- width: 100vw;
- max-width: 100vw;
- z-index: 300;
+ .radar-controls-full .radar-timeline {
+ grid-area: timeline;
+ width: 100%;
+ height: 24px;
+ }
+
+ .radar-controls-full .radar-legend { display: none; }
+ .radar-attribution {
+ overflow-wrap: anywhere;
+ padding-bottom: 0.3rem;
+ }
+
+ .tab { min-width: 88px; }
+ .search-wrap { padding: 0.45rem; }
+ .search-row {
display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 0.22rem;
- padding: 0.45rem 0.28rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
- background: rgba(9, 28, 40, 0.92);
- backdrop-filter: blur(12px);
- border-top: 1px solid rgba(121, 165, 191, 0.36);
- border-radius: 0;
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
+ gap: 0;
}
-
- .tab {
- min-height: 58px;
- flex-direction: column;
- border-radius: 12px;
- border-right: none;
- padding: 0.32rem 0.15rem;
- background: rgba(255, 255, 255, 0.04);
- color: #d8eaf4;
- gap: 0.24rem;
+ .search-row input {
+ font-size: 0.95rem;
+ padding: 0.58rem 0.68rem;
}
-
- .tab-icon {
- font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
- font-size: 1.18rem;
- line-height: 1;
+ .search-row button {
+ font-size: 0.64rem;
+ padding: 0.58rem 0.62rem;
+ letter-spacing: 0.04em;
}
-
- .tab-text {
- font-size: 0.67rem;
- line-height: 1;
- letter-spacing: 0.02em;
- font-weight: 600;
- }
-
- .tab.active {
- color: #fff;
- background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%);
- }
-
- .tagline { margin: 0; }
-
- .search-row input { min-height: 48px; }
- #search-btn { min-width: 140px; }
+ #locate-btn { font-size: 1rem !important; padding: 0.58rem 0.55rem !important; }
+ #units-btn { min-width: 56px; }
.loc-header {
- flex-direction: row;
- justify-content: space-between;
- align-items: flex-end;
- }
-
- .loc-actions { align-items: flex-end; }
-
- .current-grid { grid-template-columns: 1fr 1fr; }
- .stats-bar { grid-template-columns: repeat(6, minmax(0, 1fr)); }
-
- .astro-grid,
- .aq-grid,
- .maps-meta,
- .maps-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
-
- .maps-controls { grid-template-columns: 1fr 1fr; }
- .alerts-list { grid-template-columns: 1fr 1fr; }
-
- .day-row {
- grid-template-columns: 120px 44px 1fr 84px 64px;
- grid-template-areas: none;
+ flex-direction: column;
align-items: center;
- gap: 0.8rem;
+ text-align: center;
+ gap: 0.45rem;
+ }
+ .loc-name { font-size: 2rem; }
+ .loc-sub { font-size: 0.88rem; }
+ .loc-actions { align-items: center; margin-top: 0; }
+ .loc-time { text-align: center; font-size: 0.88rem; }
+
+ .hero { grid-template-columns: 1fr; text-align: center; }
+ .hero-left { align-items: center; }
+ .hero-right { justify-content: center; }
+ .hero-temp { font-size: 5.4rem; }
+ .hero-desc { font-size: 1.55rem; }
+ .hero-sub { font-size: 0.92rem; }
+ .hero-share-btn { font-size: 0.7rem; }
+ .feels { font-size: 0.95rem; }
+ .hilo-item { font-size: 1.35rem; }
+ .hilo-label { font-size: 0.78rem; }
+
+ .section-label { font-size: 0.76rem; font-weight: 800; }
+ .section-note { font-size: 0.78rem; color: var(--ink-mid); }
+ .stat-card .stat-label { font-size: 0.75rem; font-weight: 700; }
+ .stat-card .stat-val { font-size: 1.8rem; font-weight: 600; }
+ .stat-unit { font-size: 0.95rem; font-weight: 600; }
+ .stat-sub { font-size: 0.95rem; font-weight: 600; }
+
+ /* Sun & moon section */
+ .sun-ep-icon { font-size: 1.8rem; }
+ .sun-ep-time { font-size: 0.9rem; font-weight: 600; }
+ .moon-row { font-size: 0.95rem; font-weight: 500; }
+ .moon-emoji { font-size: 1.4rem; }
+ .daylight-info { font-size: 0.88rem; font-weight: 500; }
+
+ /* What to bring */
+ .rec-item { padding: 0.95rem 0.68rem; }
+ .rec-icon { font-size: 2.2rem; }
+ .rec-label { font-size: 0.82rem; font-weight: 600; }
+
+ /* Hourly readability */
+ .hour-cell { min-width: 88px; padding: 0.8rem 0.55rem; }
+ .hour-time { font-size: 0.88rem; }
+ .hour-icon { font-size: 1.6rem; }
+ .hour-temp { font-size: 1.08rem; }
+ .hour-precip { font-size: 0.78rem; }
+
+ /* 7-day readability */
+ .day-row { padding: 0.95rem 0.95rem; }
+ .day-name { font-size: 0.98rem; }
+ .day-icon { font-size: 1.35rem; }
+ .day-desc { font-size: 0.9rem; }
+ .day-temps { font-size: 0.95rem; }
+
+ /* Bottom nav readability */
+ .bottom-tab { min-height: 62px; gap: 0.2rem; }
+ .bottom-icon { font-size: 1.2rem; }
+ .bottom-label { font-size: 0.68rem; letter-spacing: 0.04em; }
+
+ .hero-hilo { flex-direction: row; gap: 1.5rem; padding: 0.75rem 1.2rem; }
+ .hilo-divider { width: 1px; height: 2rem; }
+ .card-group { grid-template-columns: 1fr; }
+ .recs-grid { grid-template-columns: repeat(3, 1fr); }
+ .day-row { grid-template-columns: 80px 36px 1fr 70px; }
+ .day-meta { display: none; }
+ .radar-map { height: 240px; }
+ .legend-bar { width: 50px; }
+ .settings-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
+ .settings-value { text-align: left; }
+ .map-overview { height: 350px; }
+ .maps-data { grid-template-columns: 1fr; }
+ .maps-legend { grid-template-columns: 1fr; }
+ .wind-stats { grid-template-columns: 1fr 1fr; }
+
+ .alerts-actions {
+ display: grid;
+ gap: 0.15rem;
+ justify-items: end;
}
- .day-name { grid-area: auto; }
- .day-icon { display: block; font-size: 1.36rem; text-align: center; }
- .day-desc { grid-area: auto; white-space: normal; }
- .day-temps { grid-area: auto; text-align: right; }
- .day-meta {
- display: block;
- text-align: right;
- font-size: 0.67rem;
- color: var(--ink-muted);
- line-height: 1.35;
+ .alerts-group-head {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.2rem;
}
- .saved-item { grid-template-columns: 36px 1fr 36px 36px 36px auto; }
- .saved-meta {
- display: block;
- padding: 0 0.58rem;
- font-size: 0.68rem;
- color: var(--ink-muted);
+ .learn-grid { grid-template-columns: 1fr; }
+
+ .learn-toc-links {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
- .settings-row {
- flex-direction: row;
- justify-content: space-between;
- align-items: center;
+ .learn-range-item {
+ grid-template-columns: 16px 1fr;
+ align-items: start;
}
- .section-head {
- flex-direction: row;
- justify-content: space-between;
- align-items: center;
- margin-left: 0;
- margin-right: 0;
+ .range-value,
+ .range-label,
+ .range-note {
+ grid-column: 2;
}
- .radar-timeline {
- order: 0;
- flex-basis: auto;
- flex: 1;
- min-width: 180px;
+ .learn-table-row {
+ grid-template-columns: 1fr;
+ gap: 0.2rem;
}
- .radar-ts { margin-left: auto; }
-
- .radar-map-full {
- height: calc(100vh - 300px);
- min-height: 440px;
+ .learn-table-head {
+ display: none;
}
- .tab-panel { min-height: auto; }
+ .learn-table-row span {
+ font-size: 0.8rem;
+ }
+
+ .learn-mini-grid,
+ .learn-glossary-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .learn-back-top {
+ right: 0.65rem;
+ bottom: calc(5rem + env(safe-area-inset-bottom, 0px));
+ font-size: 0.74rem;
+ padding: 0.36rem 0.6rem;
+ }
+
+ .map-overlay-readouts {
+ left: 0.45rem;
+ right: 0.45rem;
+ bottom: 0.45rem;
+ gap: 0.35rem;
+ }
+
+ .map-readout-chip {
+ font-size: 0.72rem;
+ padding: 0.26rem 0.52rem;
+ }
}
+
diff --git a/app/public/sw.js b/app/public/sw.js
index 22c715d..7350fe9 100644
--- a/app/public/sw.js
+++ b/app/public/sw.js
@@ -1,12 +1,16 @@
'use strict';
-const CACHE = 'weather-v4';
+self.addEventListener('message', event => {
+ if (event.data === 'skipWaiting') self.skipWaiting();
+});
-// App shell — cached on install for offline use
+const CACHE = 'weather-v5';
+
+// App shell cached on install for offline resilience.
const PRECACHE = [
'/',
- '/app.js?v=4',
- '/style.css?v=4',
+ '/app.js',
+ '/style.css',
'/manifest.json',
'/icons/icon.svg'
];
@@ -22,9 +26,7 @@ self.addEventListener('install', event => {
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
- .then(keys => Promise.all(
- keys.filter(k => k !== CACHE).map(k => caches.delete(k))
- ))
+ .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
.then(() => self.clients.claim())
);
});
@@ -33,7 +35,7 @@ self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
- // Skip non-GET, API calls, and map/radar tile requests
+ // Never intercept API/tile traffic.
if (
request.method !== 'GET' ||
url.pathname.startsWith('/api/') ||
@@ -45,14 +47,14 @@ self.addEventListener('fetch', event => {
return;
}
- const isAppShellRequest =
+ const isShell =
request.mode === 'navigate' ||
request.destination === 'document' ||
request.destination === 'script' ||
request.destination === 'style';
- // Prefer fresh app shell assets so UI updates are visible immediately.
- if (isAppShellRequest) {
+ if (isShell) {
+ // Network-first keeps app updates fresh; cache as fallback.
event.respondWith(
fetch(request)
.then(response => {
@@ -67,11 +69,11 @@ self.addEventListener('fetch', event => {
return;
}
+ // Cache-first for static assets.
event.respondWith(
caches.match(request).then(cached => {
if (cached) return cached;
return fetch(request).then(response => {
- // Only cache successful same-origin and trusted CDN responses
if (response.ok && (
url.origin === self.location.origin ||
url.hostname.includes('googleapis') ||
@@ -85,39 +87,4 @@ self.addEventListener('fetch', event => {
});
})
);
-});
-
-self.addEventListener('push', event => {
- let payload = { title: 'Weather alert', body: 'New weather update available.', url: '/' };
- try {
- const data = event.data ? event.data.json() : null;
- if (data) payload = { ...payload, ...data };
- } catch {
- // Ignore malformed payload and use defaults.
- }
-
- event.waitUntil(
- self.registration.showNotification(payload.title, {
- body: payload.body,
- icon: '/icons/icon.svg',
- badge: '/icons/icon.svg',
- data: { url: payload.url || '/' },
- })
- );
-});
-
-self.addEventListener('notificationclick', event => {
- event.notification.close();
- const target = event.notification?.data?.url || '/';
- event.waitUntil(
- clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => {
- const existing = windowClients.find(c => c.url.includes(self.location.origin));
- if (existing) {
- existing.focus();
- existing.navigate(target);
- return;
- }
- return clients.openWindow(target);
- })
- );
-});
+});
\ No newline at end of file
diff --git a/app/server.js b/app/server.js
index 177207e..4a09a86 100644
--- a/app/server.js
+++ b/app/server.js
@@ -1,130 +1,30 @@
const express = require('express');
const path = require('path');
-const fs = require('fs');
-const webpush = require('web-push');
const app = express();
const PORT = process.env.PORT || 3000;
+const weatherCache = new Map();
-app.use(express.json({ limit: '256kb' }));
-
-// Lightweight request logging for observability.
-app.use((req, res, next) => {
- const started = Date.now();
- res.on('finish', () => {
- const ms = Date.now() - started;
- console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
- });
- next();
-});
-
-function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-async function fetchJsonWithRetry(url, options = {}, attempts = 3, initialDelayMs = 300) {
- let lastErr = null;
- for (let i = 0; i < attempts; i++) {
- try {
- const r = await fetch(url, options);
- if (!r.ok) {
- const text = await r.text().catch(() => '');
- throw new Error(`Upstream status ${r.status}${text ? `: ${text.slice(0, 200)}` : ''}`);
- }
- return await r.json();
- } catch (e) {
- lastErr = e;
- if (i < attempts - 1) {
- await sleep(initialDelayMs * Math.pow(2, i));
- }
- }
- }
- throw lastErr || new Error('Unknown upstream fetch error');
-}
-
-// --------------- Persistent data storage ---------------
-const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
-const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json');
-const SENT_IDS_FILE = path.join(DATA_DIR, 'sent-alert-ids.json');
-
-try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {}
-
-function loadSubscriptions() {
- try {
- const arr = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8'));
- const map = new Map();
- for (const entry of arr) {
- if (entry?.subscription?.endpoint) map.set(entry.subscription.endpoint, entry);
- }
- return map;
- } catch (_) { return new Map(); }
-}
-
-function saveSubscriptions() {
- try {
- fs.writeFileSync(SUBS_FILE, JSON.stringify(Array.from(subscriptions.values()), null, 2));
- } catch (e) { console.error('Failed to save subscriptions:', e.message); }
-}
-
-const subscriptions = loadSubscriptions();
-if (subscriptions.size) console.log(`Loaded ${subscriptions.size} push subscription(s) from disk.`);
-
-function loadSentAlertIds() {
- try {
- return new Map(JSON.parse(fs.readFileSync(SENT_IDS_FILE, 'utf8')));
- } catch (_) { return new Map(); }
-}
-
-function saveSentAlertIds() {
- try {
- fs.writeFileSync(SENT_IDS_FILE, JSON.stringify(Array.from(sentAlertIds.entries()), null, 2));
- } catch (e) { console.error('Failed to save sent alert IDs:', e.message); }
-}
-
-const sentAlertIds = loadSentAlertIds();
-// --------------------------------------------------------
-
-function getVapidConfig() {
- const publicKey = process.env.VAPID_PUBLIC_KEY || '';
- const privateKey = process.env.VAPID_PRIVATE_KEY || '';
- const subject = process.env.VAPID_SUBJECT || 'mailto:nate@necloud.us';
- return { publicKey, privateKey, subject };
-}
-
-function pushConfigured() {
- const cfg = getVapidConfig();
- return Boolean(cfg.publicKey && cfg.privateKey);
-}
-
-function ensurePushConfigured() {
- const cfg = getVapidConfig();
- if (!cfg.publicKey || !cfg.privateKey) {
- return false;
- }
- webpush.setVapidDetails(cfg.subject, cfg.publicKey, cfg.privateKey);
- return true;
-}
-
-// HTML: never cache (always revalidate so SW/app updates are picked up immediately)
+// Keep shell assets fresh so SW/app versions do not drift.
app.use('/', (req, res, next) => {
if (req.path === '/' || req.path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-store');
- } else if (req.path.endsWith('.css') || req.path.endsWith('.js') || req.path === '/sw.js' || req.path === '/manifest.json') {
- // Short-lived cache — browser revalidates every 60 s but still gets a 304 fast
+ } else if (
+ req.path.endsWith('.css') ||
+ req.path.endsWith('.js') ||
+ req.path === '/sw.js' ||
+ req.path === '/manifest.json'
+ ) {
res.setHeader('Cache-Control', 'no-cache');
}
next();
});
+
app.use(express.static(path.join(__dirname, 'public')));
-app.get('/healthz', (_req, res) => {
- res.json({
- ok: true,
- uptimeSec: Math.round(process.uptime()),
- pushConfigured: pushConfigured(),
- subscriptionCount: subscriptions.size,
- now: new Date().toISOString(),
- });
+// Prevent browser favicon requests from showing noisy 404s.
+app.get('/favicon.ico', (_req, res) => {
+ res.status(204).end();
});
// Geocoding proxy
@@ -133,8 +33,58 @@ app.get('/api/geocode', async (req, res) => {
if (!q) return res.status(400).json({ error: 'Missing query' });
try {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`;
- const data = await fetchJsonWithRetry(url);
- res.json(data);
+
+ let openMeteoData = null;
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 9000);
+ try {
+ const r = await fetch(url, { signal: controller.signal });
+ clearTimeout(timeoutId);
+ if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`);
+ openMeteoData = await r.json();
+ break;
+ } catch (err) {
+ clearTimeout(timeoutId);
+ if (attempt === 2) {
+ console.warn('Open-Meteo geocode failed after retries:', err.message);
+ } else {
+ await new Promise(resolve => setTimeout(resolve, 300 * attempt));
+ }
+ }
+ }
+
+ if (openMeteoData?.results?.length) {
+ return res.json(openMeteoData);
+ }
+
+ // Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable.
+ const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' });
+ const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, {
+ headers: {
+ 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
+ Accept: 'application/json',
+ },
+ });
+
+ if (!nom.ok) {
+ throw new Error(`Nominatim geocode status ${nom.status}`);
+ }
+
+ const nomData = await nom.json();
+ const results = (nomData || []).map(item => {
+ const address = item.address || {};
+ return {
+ name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown',
+ latitude: Number(item.lat),
+ longitude: Number(item.lon),
+ country: address.country || '',
+ admin1: address.state || address.region || address.county || '',
+ timezone: 'auto',
+ };
+ }).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude));
+
+ return res.json({ results });
} catch (e) {
res.status(500).json({ error: 'Geocoding failed', detail: e.message });
}
@@ -146,31 +96,6 @@ app.get('/api/reverse-geocode', async (req, res) => {
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try {
- // Prefer Open-Meteo reverse geocoding for stable city/region labels.
- const omParams = new URLSearchParams({
- latitude: String(lat),
- longitude: String(lon),
- count: '1',
- language: 'en',
- format: 'json',
- });
-
- const omData = await fetchJsonWithRetry(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`, {}, 2, 200).catch(() => null);
- if (omData) {
- const top = omData?.results?.[0];
- if (top) {
- return res.json({
- name: top.name || top.admin2 || top.admin1 || top.country || 'Detected location',
- admin1: top.admin1 || top.admin2 || '',
- country: top.country || '',
- latitude: Number(lat),
- longitude: Number(lon),
- timezone: top.timezone || 'auto',
- display_name: [top.name, top.admin1, top.country].filter(Boolean).join(', '),
- });
- }
- }
-
const params = new URLSearchParams({
format: 'jsonv2',
lat: String(lat),
@@ -179,13 +104,18 @@ app.get('/api/reverse-geocode', async (req, res) => {
addressdetails: '1',
});
- const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, {
+ const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/json',
},
});
+ if (!r.ok) {
+ throw new Error(`Reverse geocoding failed with status ${r.status}`);
+ }
+
+ const data = await r.json();
const address = data.address || {};
const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location';
@@ -209,13 +139,14 @@ app.get('/api/weather', async (req, res) => {
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
const timezone = tz || 'auto';
const useMetric = units === 'metric';
+ const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`;
try {
const params = new URLSearchParams({
latitude: lat, longitude: lon, timezone,
current: [
'temperature_2m','relative_humidity_2m','apparent_temperature',
'is_day','precipitation','weather_code','cloud_cover',
- 'wind_speed_10m','wind_direction_10m','surface_pressure','visibility'
+ 'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility'
].join(','),
hourly: [
'temperature_2m','precipitation_probability','weather_code',
@@ -231,9 +162,44 @@ app.get('/api/weather', async (req, res) => {
temperature_unit: useMetric ? 'celsius' : 'fahrenheit',
precipitation_unit: useMetric ? 'mm' : 'inch'
});
- const data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`);
- res.json(data);
+
+ let lastError = null;
+ const upstreamTimeoutMs = 12000;
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs);
+ try {
+ const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal });
+ clearTimeout(timeoutId);
+ const body = await r.text();
+
+ if (!r.ok) {
+ throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`);
+ }
+
+ const data = JSON.parse(body);
+ weatherCache.set(cacheKey, { data, at: Date.now() });
+ return res.json(data);
+ } catch (err) {
+ clearTimeout(timeoutId);
+ lastError = err;
+ const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error');
+ console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`);
+ if (attempt < 3) {
+ await new Promise(resolve => setTimeout(resolve, 500 * attempt));
+ }
+ }
+ }
+
+ throw lastError || new Error('Weather upstream failed');
} catch (e) {
+ const cached = weatherCache.get(cacheKey);
+ if (cached && Date.now() - cached.at < 45 * 60 * 1000) {
+ res.setHeader('X-Weather-Cache', 'stale-fallback');
+ console.warn('Weather proxy using stale cache for', cacheKey);
+ return res.json(cached.data);
+ }
+ console.error('Weather proxy failed:', e.message);
res.status(500).json({ error: 'Weather fetch failed', detail: e.message });
}
});
@@ -250,10 +216,34 @@ app.get('/api/air-quality', async (req, res) => {
current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','),
forecast_days: 1,
});
- const data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`);
- res.json(data);
+ let lastError = null;
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
+ try {
+ const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, {
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutId);
+ if (!r.ok) {
+ throw new Error(`Open-Meteo status ${r.status}`);
+ }
+ const data = await r.json();
+ return res.json(data);
+ } catch (err) {
+ clearTimeout(timeoutId);
+ lastError = err;
+ if (attempt < 2) {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ }
+ }
+ }
+
+ throw lastError || new Error('Air quality upstream failed');
} catch (e) {
- res.status(500).json({ error: 'Air quality fetch failed', detail: e.message });
+ console.error('Air quality fetch error:', e);
+ res.setHeader('X-Air-Quality-Fallback', 'empty');
+ res.json({ current: {} });
}
});
@@ -262,211 +252,73 @@ app.get('/api/alerts', async (req, res) => {
const { lat, lon } = req.query;
if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
try {
- const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
+
+ const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
headers: {
'User-Agent': 'weather-app-v2 (nate@necloud.us)',
Accept: 'application/geo+json',
},
+ signal: controller.signal,
});
+
+ clearTimeout(timeoutId);
+
+ if (!r.ok) {
+ throw new Error(`NWS API returned ${r.status}`);
+ }
+ const data = await r.json();
res.json(data);
} catch (e) {
- res.status(500).json({ error: 'Alerts fetch failed', detail: e.message });
+ console.error('Alerts fetch error:', e.message);
+ res.setHeader('X-Alerts-Fallback', 'empty');
+ res.json({ features: [] });
}
});
-// Lightning activity from active weather.gov thunderstorm alerts (real alert geometries).
-app.get('/api/lightning', async (req, res) => {
- const { lat, lon, age } = req.query;
- if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' });
- const ageMinutes = Number(age || 15);
-
+// Radar/satellite tile configuration
+app.get('/api/radar/times', async (req, res) => {
try {
- const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, {
- headers: {
- 'User-Agent': 'weather-app-v2 (nate@necloud.us)',
- Accept: 'application/geo+json',
- },
+ const now = Math.floor(Date.now() / 1000);
+
+ // IEM NEXRAD tile service (US radar, public/free) with historical offsets for animation.
+ const radarLayerBase = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0';
+ const radarOffsetsMin = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0];
+ const radarPast = radarOffsetsMin.map(offset => {
+ const layer = offset === 0 ? 'nexrad-n0q-900913' : `nexrad-n0q-900913-m${String(offset).padStart(2, '0')}m`;
+ return {
+ time: now - (offset * 60),
+ url: `${radarLayerBase}/${layer}/{z}/{x}/{y}.png`,
+ isForecast: false,
+ maxNativeZoom: 12,
+ };
});
- const cutoff = Date.now() - (Number.isFinite(ageMinutes) ? ageMinutes : 15) * 60 * 1000;
- const features = (data?.features || []).filter(f => {
- const p = f?.properties || {};
- const event = String(p.event || '').toLowerCase();
- const relevant = event.includes('thunderstorm') || event.includes('lightning');
- if (!relevant) return false;
- const ts = Date.parse(p.sent || p.onset || p.effective || '');
- if (Number.isNaN(ts)) return true;
- return ts >= cutoff;
+ // IEM GOES East cloud/satellite frames for animation.
+ const satOffsetsMin = [30, 25, 20, 15, 10, 5, 0];
+ const satelliteInfrared = satOffsetsMin.map(offset => {
+ const layer = offset === 0 ? 'goes_east' : `goes_east_m${String(offset).padStart(2, '0')}m`;
+ return {
+ time: now - (offset * 60),
+ url: `https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/${layer}/{z}/{x}/{y}.png`,
+ isForecast: false,
+ maxNativeZoom: 10,
+ };
});
res.json({
- source: 'weather.gov alerts',
- count: features.length,
- features,
- ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15,
+ provider: 'iem-radar-rainviewer-sat',
+ radar: {
+ past: radarPast,
+ nowcast: [],
+ },
+ satellite: {
+ infrared: satelliteInfrared,
+ },
});
} catch (e) {
- res.status(500).json({ error: 'Lightning fetch failed', detail: e.message });
- }
-});
-
-app.get('/api/push/public-key', (_req, res) => {
- const { publicKey } = getVapidConfig();
- res.json({
- enabled: pushConfigured(),
- publicKey: publicKey || null,
- });
-});
-
-app.post('/api/push/subscribe', (req, res) => {
- const { subscription, location } = req.body || {};
- if (!subscription?.endpoint) {
- return res.status(400).json({ error: 'Missing subscription endpoint' });
- }
- subscriptions.set(subscription.endpoint, {
- subscription,
- location: location || null,
- createdAt: Date.now(),
- });
- saveSubscriptions();
- res.json({ ok: true, count: subscriptions.size });
-});
-
-app.post('/api/push/unsubscribe', (req, res) => {
- const { endpoint } = req.body || {};
- if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' });
- subscriptions.delete(endpoint);
- saveSubscriptions();
- res.json({ ok: true, count: subscriptions.size });
-});
-
-app.post('/api/push/test', async (req, res) => {
- if (!ensurePushConfigured()) {
- return res.status(400).json({
- error: 'Push is not configured. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.',
- });
- }
-
- const payload = JSON.stringify({
- title: req.body?.title || 'Weather test notification',
- body: req.body?.body || 'Push notifications are working.',
- url: req.body?.url || '/',
- ts: Date.now(),
- });
-
- let sent = 0;
- let removed = 0;
- const errors = [];
-
- const entries = Array.from(subscriptions.values());
- for (const entry of entries) {
- try {
- await webpush.sendNotification(entry.subscription, payload);
- sent++;
- } catch (e) {
- const statusCode = e?.statusCode;
- if (statusCode === 404 || statusCode === 410) {
- subscriptions.delete(entry.subscription.endpoint);
- removed++;
- saveSubscriptions();
- } else {
- errors.push(String(e.message || e));
- }
- }
- }
-
- res.json({ ok: true, sent, removed, errors, count: subscriptions.size });
-});
-
-// --------------- Background NWS alert poller ---------------
-// Runs every ALERT_POLL_INTERVAL_MS (default 5 min).
-// Sends push notifications to all subscribers for any new active NWS alerts
-// at their saved location. Deduplicates by alert ID (persisted to disk).
-
-function cleanSentAlertIds() {
- const cutoff = Date.now() - 48 * 60 * 60 * 1000;
- for (const [id, ts] of sentAlertIds) {
- if (ts < cutoff) sentAlertIds.delete(id);
- }
-}
-
-async function alertPoller() {
- if (!pushConfigured() || !ensurePushConfigured()) return;
- if (subscriptions.size === 0) return;
-
- cleanSentAlertIds();
-
- // Group subscribers by rounded lat/lon so we don't spam the NWS API.
- const byLocation = new Map();
- for (const entry of subscriptions.values()) {
- const loc = entry.location;
- if (!loc?.latitude || !loc?.longitude) continue;
- const key = `${Number(loc.latitude).toFixed(2)},${Number(loc.longitude).toFixed(2)}`;
- if (!byLocation.has(key)) byLocation.set(key, { loc, entries: [] });
- byLocation.get(key).entries.push(entry);
- }
-
- let savedIds = false;
- for (const { loc, entries } of byLocation.values()) {
- try {
- const data = await fetchJsonWithRetry(
- `https://api.weather.gov/alerts/active?point=${loc.latitude},${loc.longitude}`,
- { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } }
- ).catch(() => null);
- if (!data?.features?.length) continue;
-
- for (const feature of data.features) {
- const id = feature?.properties?.id;
- if (!id || sentAlertIds.has(id)) continue;
-
- const props = feature.properties || {};
- const title = props.event || 'Weather Alert';
- const area = props.areaDesc ? props.areaDesc.split(';')[0].trim() : (loc.name || 'Your area');
- const headline = props.headline || props.description?.slice(0, 120) || title;
- const payload = JSON.stringify({
- title,
- body: `${area}: ${headline}`,
- url: '/',
- ts: Date.now(),
- });
-
- let anySuccess = false;
- for (const entry of entries) {
- try {
- await webpush.sendNotification(entry.subscription, payload);
- anySuccess = true;
- } catch (e) {
- if (e?.statusCode === 404 || e?.statusCode === 410) {
- subscriptions.delete(entry.subscription.endpoint);
- saveSubscriptions();
- }
- }
- }
-
- if (anySuccess) {
- console.log(`Alert push sent: [${id}] ${title} → ${area}`);
- sentAlertIds.set(id, Date.now());
- savedIds = true;
- }
- }
- } catch (e) {
- console.error('Alert poller error:', e.message);
- }
- }
-
- if (savedIds) saveSentAlertIds();
-}
-
-const POLL_INTERVAL_MS = Number(process.env.ALERT_POLL_INTERVAL_MS) || 5 * 60 * 1000;
-setInterval(alertPoller, POLL_INTERVAL_MS);
-// -----------------------------------------------------------
-
-// RainViewer timestamps proxy
-app.get('/api/radar/times', async (req, res) => {
- try {
- const data = await fetchJsonWithRetry('https://api.rainviewer.com/public/weather-maps.json');
- res.json(data);
- } catch (e) {
+ console.error('Radar config error:', e.message);
res.status(500).json({ error: 'Radar times failed', detail: e.message });
}
});
diff --git a/docker-compose.yml b/docker-compose.yml
index a7617fe..f5ad626 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,8 @@
services:
weather:
- image: ghcr.io/nmemmert/weather:latest
+ build:
+ context: ./app
+ dockerfile: Dockerfile
container_name: weather-app
restart: unless-stopped
ports:
diff --git a/served.html b/served.html
new file mode 100644
index 0000000..5060835
Binary files /dev/null and b/served.html differ