diff --git a/docs/user_guide.md b/docs/user_guide.md index 88af7f2..92c46b6 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -480,6 +480,18 @@ Google Account when the hub is retired or no longer uses that account. #### Weather Forecast +Both forecast tiles include a forecast synopsis. Open-Meteo and MET Norway +summaries are assembled from hourly conditions, temperature range, maximum wind, +and peak precipitation chance, using the selected display units. NWS supplies +its own period narrative when available; the period name is shown and its text +keeps the provider’s original units. If that optional text is unavailable, +Sensorius builds the same hourly summary used for the other providers. + +The Caelus forecast card also shows temperature, humidity, and wind ranges and +hourly humidity and wind alongside precipitation chances. Both cards retain +their existing footprint; focus or scroll the synopsis to read longer text. +Missing provider values appear as dashes rather than a zero forecast. + The full-screen forecast is titled **Living Weather**, with the Caelus icon and “Created by Peace Hill Studios” beneath the title. The Biodynamic Calendar includes this credit in the central calendar icon. The settings menu displays diff --git a/sensorius/__init__.py b/sensorius/__init__.py index 9a9e649..d06c6ca 100644 --- a/sensorius/__init__.py +++ b/sensorius/__init__.py @@ -4,4 +4,4 @@ release notes, and supporting tooling can report a consistent build identity. """ -__version__ = "v0.26.253.1" +__version__ = "v0.26.253.5" diff --git a/sensorius/saiHtml.py b/sensorius/saiHtml.py index 60380ae..eddd3f9 100644 --- a/sensorius/saiHtml.py +++ b/sensorius/saiHtml.py @@ -1751,7 +1751,7 @@ def _status_color_hex(status: str) -> str: yield "#weatherForecastBox .astro-title{width:100%;text-align:center;color:var(--forecast-ink);text-shadow:0 1px 2px rgba(0,0,0,.24);}" yield ".forecast-status{font-size:.55rem;line-height:1.1;text-align:center;color:var(--forecast-muted);min-height:1.1em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}" yield ".forecast-current{border:1px solid var(--forecast-border);border-radius:8px;background:var(--forecast-panel);padding:.42rem .48rem;display:flex;flex-direction:column;gap:.3rem;min-width:0;backdrop-filter:blur(3px);}" - yield ".forecast-current-summary{font-size:.68rem;line-height:1.16;color:var(--forecast-ink);text-align:center;min-height:2.35em;overflow-wrap:anywhere;}" + yield ".forecast-current-summary{font-size:.68rem;line-height:1.16;color:var(--forecast-ink);text-align:center;height:2.35em;overflow:auto;overflow-wrap:anywhere;}" yield ".forecast-current-grid{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.15rem .4rem;align-items:start;font-size:.64rem;line-height:1.12;}" yield ".forecast-current-grid dt{font-weight:700;color:var(--forecast-muted);margin:0;}" yield ".forecast-current-grid dd{margin:0;color:var(--forecast-ink);overflow-wrap:anywhere;}" @@ -1946,7 +1946,7 @@ def _status_color_hex(status: str) -> str: yield " " yield "
" yield "
" - yield "
--
" + yield "
--
" yield "
" yield "
Temp
--
" yield "
RH
--
" @@ -3557,7 +3557,7 @@ def _format_stat_value(stat_value): yield " const provider = forecastProviderLabel(data);" yield " const cacheText = data.stale ? 'Cached' : 'Live';" yield " if (statusEl) statusEl.textContent = provider ? `${cacheText} - ${provider}` : cacheText;" - yield " setForecastText('forecastOverall', cur.overall || cur.forecast || '--');" + yield " setForecastText('forecastOverall', cur.synopsis || cur.overall || cur.forecast || '--');" yield " setForecastText('forecastTempRange', forecastReadingAndRange(forecastCurrentTemperature(data), cur.temp_range));" yield " setForecastWind(cur.wind || '--');" yield " setForecastText('forecastRhRange', forecastReadingAndRange(forecastCurrentHumidity(data), cur.rh_range));" diff --git a/sensorius/saiWeatherForecast.py b/sensorius/saiWeatherForecast.py index 80d486b..f6732ce 100644 --- a/sensorius/saiWeatherForecast.py +++ b/sensorius/saiWeatherForecast.py @@ -377,6 +377,17 @@ def normalize_met_forecast(payload: dict[str, Any], *, tz_name: str) -> list[dic return sorted(rows, key=lambda row: row["time"]) +def _open_meteo_symbol(value: object) -> str: + code = _safe_float(value) + if code in (95, 96, 99): + return "Thunderstorms" + if code in (71, 73, 75, 77, 85, 86): + return "Snow" + if code in (51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82): + return "Rain" + return {0: "Clear", 1: "Mostly clear", 2: "Partly cloudy", 3: "Overcast", 45: "Fog", 48: "Fog"}.get(code, "") + + def normalize_open_meteo_forecast(payload: dict[str, Any], *, tz_name: str) -> list[dict[str, Any]]: """Normalize Open-Meteo forecast payload into hourly rows.""" hourly = payload.get("hourly") if isinstance(payload, dict) else None @@ -389,6 +400,7 @@ def normalize_open_meteo_forecast(payload: dict[str, Any], *, tz_name: str) -> l precip_probability = hourly.get("precipitation_probability") clouds = hourly.get("cloud_cover") winds = hourly.get("wind_speed_10m") + codes = hourly.get("weather_code") if not isinstance(times, list): return [] rows: list[dict[str, Any]] = [] @@ -402,6 +414,7 @@ def _at(values: object) -> object: "temp_c": _at(temps), "rh": _at(rhs), "wind_mps": _at(winds), + "symbol": _open_meteo_symbol(_at(codes)), "cloud": _at(clouds), "precip_mm": _at(precip) or 0.0, "precip_probability": _at(precip_probability), @@ -750,7 +763,7 @@ async def _fetch_probability_supplement() -> list[dict[str, Any]]: async def _fetch_open_meteo_forecast(latitude: float, longitude: float, *, tz_name: str, timeout_sec: float) -> list[dict[str, Any]]: hourly = ( "temperature_2m,relative_humidity_2m,precipitation," - "precipitation_probability,cloud_cover,wind_speed_10m" + "precipitation_probability,cloud_cover,wind_speed_10m,weather_code" ) async with httpx.AsyncClient(timeout=timeout_sec, headers={"User-Agent": USER_AGENT}, follow_redirects=True) as client: resp = await client.get( @@ -780,17 +793,20 @@ async def _fetch_nws_forecast(latitude: float, longitude: float, *, tz_name: str if not hourly_url: raise RuntimeError("NWS hourly forecast URL unavailable") - async def _fetch_grid() -> httpx.Response | None: - if not grid_url: + async def _fetch_optional(url: str) -> httpx.Response | None: + if not url: return None try: - return await client.get(grid_url) + return await client.get(url) except Exception as exc: if DEBUG: - printDM(f"NWS grid probability request failed: {exc}", location=MODULE) + printDM(f"NWS optional forecast request failed: {exc}", location=MODULE) return None - hourly_resp, grid_resp = await asyncio.gather(client.get(hourly_url), _fetch_grid()) + narrative_url = str((props or {}).get("forecast") or "").strip() + hourly_resp, grid_resp, narrative_resp = await asyncio.gather( + client.get(hourly_url), _fetch_optional(grid_url), _fetch_optional(narrative_url) + ) hourly_resp.raise_for_status() rows = normalize_nws_forecast(hourly_resp.json(), tz_name=tz_name) if grid_resp is not None: @@ -802,9 +818,32 @@ async def _fetch_grid() -> httpx.Response | None: printDM(f"NWS grid probability supplement failed: {exc}", location=MODULE) else: supplement_precipitation_probabilities(rows, supplemental) + if narrative_resp is not None: + try: + narrative_resp.raise_for_status() + attach_nws_narratives(rows, narrative_resp.json()) + except Exception as exc: + if DEBUG: + printDM(f"NWS narrative supplement failed: {exc}", location=MODULE) return rows +def attach_nws_narratives(rows: list[dict[str, Any]], payload: dict[str, Any]) -> None: + """Attach native NWS text only to hours within its forecast period.""" + properties = payload.get("properties") or {} + for period in properties.get("periods") or []: + start = _parse_datetime(period.get("startTime")) + end = _parse_datetime(period.get("endTime")) + text = str(period.get("detailedForecast") or "").strip() + if not start or not end or not text: + continue + for row in rows: + instant = _parse_datetime(row.get("time")) + if instant and start <= instant < end: + row["narrative"] = text + row["narrative_period"] = str(period.get("name") or "Forecast period") + + def _resolve_forecast_location(settings: Any, *, timeout_sec: float) -> dict[str, Any]: try: resolved = settings.resolve_astral_location(persist_if_auto=False, timeout_sec=timeout_sec) or {} diff --git a/sensorius/saiWeatherForecastApp.py b/sensorius/saiWeatherForecastApp.py index 0d7aa62..f068c84 100644 --- a/sensorius/saiWeatherForecastApp.py +++ b/sensorius/saiWeatherForecastApp.py @@ -308,6 +308,44 @@ def _hour_window_condition(hours: list[dict[str, Any]]) -> str: return "Clear" +def _forecast_synopsis(hours: list[dict[str, Any]], unit_system: str, provider: str) -> str: + """Describe the upcoming hourly window, preferring its native NWS period text.""" + if not hours: + return "" + if provider == "us" and hours[0].get("narrative"): + return f"{hours[0].get('narrative_period') or 'Forecast period'} (NWS · original units): {hours[0]['narrative']}" + names = {"sunny": "Clear", "partly-cloudy": "Partly cloudy", "cloudy": "Overcast", + "rain": "Rain", "snow": "Snow", "thunder": "Thunderstorms", "fog": "Fog"} + def condition(row): + value = _hour_window_condition([row]) + return "Conditions unavailable" if value == "Forecast unavailable" else names[_condition_icon_key(value)] + def when(row): + value = str(row.get("local_time") or row.get("time") or "") + first = str(hours[0].get("local_time") or hours[0].get("time") or "") + return _format_hour_label(value) + (" next day" if value[:10] != first[:10] else "") + first_condition = condition(hours[0]) + text = first_condition + for row in hours[1:]: + next_condition = condition(row) + if next_condition != first_condition: + text += f", then {next_condition.lower()} around {when(row)}" + break + parts = [text + "."] + temperatures = [value for row in hours if (value := _forecast_display_value(row.get("temp_c"), "Temperature", "°C", unit_system)[0]) is not None] + temp_unit = _forecast_display_value(None, "Temperature", "°C", unit_system)[1] + if temperatures: + parts.append(f"Temperatures {min(temperatures):.0f}–{max(temperatures):.0f}{temp_unit}.") + winds = [value for row in hours if (value := _forecast_display_value(row.get("wind_mps"), "Wind Speed", "m/s", unit_system)[0]) is not None] + wind_unit = _forecast_display_value(None, "Wind Speed", "m/s", unit_system)[1] + if winds: + parts.append(f"Winds up to {max(winds):.0f} {wind_unit}.") + probabilities = [row for row in hours if _safe_float(row.get("precip_probability")) is not None] + if probabilities: + peak = max(probabilities, key=lambda row: float(row["precip_probability"])) + parts.append(f"Precipitation chance peaks at {float(peak['precip_probability']):.0f}% around {when(peak)}.") + return " ".join(parts) + + def build_weather_display_forecast( payload: dict[str, Any], unit_system: object = "Imperial", @@ -332,6 +370,9 @@ def build_weather_display_forecast( hours.append( { "label": label, + "humidity": round(rh) if (rh := _safe_float(raw.get("rh"))) is not None else None, + "wind_speed": round(wind) if (wind := _forecast_display_value(raw.get("wind_mps"), "Wind Speed", "m/s", display_unit_system)[0]) is not None else None, + "wind_unit": _forecast_display_value(None, "Wind Speed", "m/s", display_unit_system)[1], "icon": _condition_icon(condition), "icon_key": _condition_icon_key(condition), "precip_label": _precipitation_chance_label(condition), @@ -404,6 +445,8 @@ def build_weather_display_forecast( "stale": bool(payload.get("stale", False)), "reason": str(payload.get("reason") or ""), "condition": condition, + "synopsis": _forecast_synopsis(normalized_hours, display_unit_system, str(payload.get("provider") or "")) or condition, + "wind_range": _forecast_wind(current.get("wind"), display_unit_system, include_description=False), "icon": _condition_icon(_hour_window_condition(normalized_hours[:3]) if normalized_hours else condition), "icon_key": _condition_icon_key( _hour_window_condition(normalized_hours[:3]) if normalized_hours else condition diff --git a/sensorius/saiWebRoutes.py b/sensorius/saiWebRoutes.py index 82bdabc..1effdd1 100644 --- a/sensorius/saiWebRoutes.py +++ b/sensorius/saiWebRoutes.py @@ -6163,6 +6163,7 @@ async def api_weather_forecast( payload = copy.deepcopy(payload) current_24h = payload.get("current_24h") if isinstance(current_24h, dict): + current_24h["synopsis"] = display_forecast.get("synopsis") current_24h["temp_range"] = display_forecast.get("temp_range") or current_24h.get("temp_range") payload["unit_system"] = display_unit_system if forecast_app_service is not None: diff --git a/testApparatus/playwright_host.py b/testApparatus/playwright_host.py index 761d179..4d15bf4 100644 --- a/testApparatus/playwright_host.py +++ b/testApparatus/playwright_host.py @@ -127,19 +127,21 @@ def switch_settings() -> HTMLResponse: @app.get("/weather-forecast", response_class=HTMLResponse) -def weather_forecast(request: Request, units: str = "Metric"): +def weather_forecast(request: Request, units: str = "Metric", native: bool = False): """Render the real Caelus page with a complete, deterministic six-day outlook.""" from sensorius.saiWeatherForecastApp import build_weather_display_forecast start = datetime(2026, 9, 9, 15, tzinfo=timezone.utc) forecast = build_weather_display_forecast({ - "ok": True, "provider": "nws", - "current_24h": {"overall": "Cloudy early, clearing late", "precip_probability": 55}, + "ok": True, "provider": "us" if native else "open_meteo", + "current_24h": {"overall": "Cloudy early, clearing late", "precip_probability": 55, "rh_range": "35-85%", "wind": "Mostly light\n1-8 m/s / 2-18 mph"}, "hourly": [{ "time": (start + timedelta(hours=hour)).isoformat(), "local_time": (start + timedelta(hours=hour)).isoformat(), "temp_c": 30 - hour / 2, "precip_probability": 55, - "symbol": "partlycloudy_day", + "narrative": "Sunny, with a high near 80. West wind 5 to 10 mph. " * 12 if native else "", + "narrative_period": "This Afternoon", + "symbol": "partlycloudy_day" if hour < 5 else "rain", "rh": 55, "wind_mps": 4, } for hour in range(24)], "days": [{ "date": (start + timedelta(days=day)).date().isoformat(), diff --git a/testApparatus/test_integrated_weather_forecast.py b/testApparatus/test_integrated_weather_forecast.py index 412f806..c3f95f2 100644 --- a/testApparatus/test_integrated_weather_forecast.py +++ b/testApparatus/test_integrated_weather_forecast.py @@ -579,7 +579,7 @@ async def _fake_forecast(*_args, **_kwargs): assert "data-hourly-previous" in page.text assert "data-hourly-next" in page.text assert ">40% Rain chance" in page.text - assert "40% rain chance" in page.text + assert ">Rain 40%" in page.text assert "Rain 42%" in page.text assert "RH 30-60%" in page.text assert "Wind --" in page.text @@ -859,3 +859,37 @@ def test_six_day_dialog_uses_selected_theme_palette(): assert ".forecast-dialog > header button::before" in css assert "transform: translate(-50%, -50%) rotate(45deg)" in css assert "transform: translate(-50%, -50%) rotate(-45deg)" in css + + +def test_synopsis_uses_local_transition_time_units_and_missing_values(): + payload = {"ok": True, "provider": "met_no", "hourly": [ + {"local_time": "2026-09-10T23:00:00-06:00", "temp_c": 10, "symbol": "clearsky_night", "wind_mps": 2}, + {"local_time": "2026-09-11T00:00:00-06:00", "temp_c": 20, "symbol": "snow", "wind_mps": 4, "rh": 60, "precip_probability": 75}, + ]} + metric = weather_app.build_weather_display_forecast(payload, "Metric") + assert "then snow around 12 AM next day" in metric["synopsis"] + assert "10–20°C" in metric["synopsis"] + assert "14 km/h" in metric["synopsis"] + assert "75% around 12 AM next day" in metric["synopsis"] + assert metric["hours"][0]["humidity"] is None + assert metric["hours"][1]["humidity"] == 60 + imperial = weather_app.build_weather_display_forecast(payload, "Imperial") + assert "50–68°F" in imperial["synopsis"] + assert "9 mph" in imperial["synopsis"] + for hour in payload["hourly"]: + hour.pop("precip_probability", None) + hour.pop("wind_mps", None) + missing = weather_app.build_weather_display_forecast(payload)["synopsis"] + assert "chance" not in missing + assert "Winds" not in missing + + +def test_synopsis_prefers_nws_native_text_with_generated_fallback(): + payload = {"ok": True, "provider": "us", "hourly": [{ + "local_time": "2026-09-10T12:00:00Z", "temp_c": 20, "symbol": "Sunny", + "narrative_period": "This Afternoon", "narrative": "Sunny, with a high near 80.", + }]} + result = weather_app.build_weather_display_forecast(payload, "Metric") + assert result["synopsis"] == "This Afternoon (NWS · original units): Sunny, with a high near 80." + del payload["hourly"][0]["narrative"] + assert "Temperatures 20–20°C" in weather_app.build_weather_display_forecast(payload, "Metric")["synopsis"] diff --git a/testApparatus/test_weather_forecast.py b/testApparatus/test_weather_forecast.py index c916749..b2b1fe7 100644 --- a/testApparatus/test_weather_forecast.py +++ b/testApparatus/test_weather_forecast.py @@ -305,3 +305,58 @@ def test_weather_forecast_cache_normalizes_legacy_display_strings(tmp_path): assert loaded["current_24h"]["wind"] == "Mostly light/moderate\n2-4 m/s / 4-9 mph" assert loaded["current_24h"]["rh_range"] == "34-64%" assert loaded["days"][0]["temp_range"] == "15.1-26.5°C / 59-80°F" + + +def test_nws_narrative_is_bound_to_its_period(): + from sensorius.saiWeatherForecast import attach_nws_narratives + rows = [{"time": f"2026-09-10T{hour:02}:00:00Z"} for hour in (5, 6, 17, 18)] + attach_nws_narratives(rows, {"properties": {"periods": [{ + "startTime": "2026-09-10T06:00:00Z", "endTime": "2026-09-10T18:00:00Z", + "name": "Today", "detailedForecast": "Sunny, with a high near 80.", + }]}}) + assert "narrative" not in rows[0] + assert rows[1]["narrative_period"] == "Today" + assert rows[2]["narrative"] == "Sunny, with a high near 80." + assert "narrative" not in rows[3] + + +def test_open_meteo_preserves_snow_and_thunder_codes(): + rows = normalize_open_meteo_forecast({"hourly": { + "time": ["2026-09-10T12:00", "2026-09-10T13:00"], + "temperature_2m": [0, 1], "weather_code": [73, 95], + }}, tz_name="UTC") + assert [row["symbol"] for row in rows] == ["Snow", "Thunderstorms"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("narrative_status", [200, 503]) +async def test_nws_fetch_uses_optional_period_narrative(monkeypatch, narrative_status): + import httpx + import sensorius.saiWeatherForecast as forecast + + requested = [] + def respond(request): + requested.append(request.url.path) + if request.url.path.startswith("/points/"): + return httpx.Response(200, json={"properties": { + "forecastHourly": "https://api.weather.gov/hourly", + "forecast": "https://api.weather.gov/narrative", + }}) + if request.url.path == "/hourly": + return httpx.Response(200, json={"properties": {"periods": [{ + "startTime": "2026-09-10T12:00:00Z", "temperature": 68, + "temperatureUnit": "F", "shortForecast": "Sunny", + }]}}) + return httpx.Response(narrative_status, json={"properties": {"periods": [{ + "startTime": "2026-09-10T06:00:00Z", "endTime": "2026-09-10T18:00:00Z", + "name": "Today", "detailedForecast": "Sunny, with a high near 80.", + }]}}) + client_class = httpx.AsyncClient + monkeypatch.setattr(forecast.httpx, "AsyncClient", lambda **kwargs: client_class( + transport=httpx.MockTransport(respond), **kwargs + )) + rows = await forecast._fetch_nws_forecast(40, -105, tz_name="UTC", timeout_sec=1) + assert "/narrative" in requested + assert len(rows) == 1 + assert rows[0]["temp_c"] == 20 + assert ("narrative" in rows[0]) == (narrative_status == 200) diff --git a/tests/dashboard.spec.js b/tests/dashboard.spec.js index 4f08c2c..1f98bf0 100644 --- a/tests/dashboard.spec.js +++ b/tests/dashboard.spec.js @@ -365,3 +365,25 @@ test('refreshes warming Sun and Moon data while a dashboard image is still loadi releaseImage(); } }); + +test('forecast synopsis stays readable without enlarging the dashboard tile', async ({ page }) => { + let synopsis = 'Sunny.'; + await page.route(url => url.pathname === '/api/weather-forecast', route => route.fulfill({ json: { + ok: true, provider: 'us', hourly: [], days: [], + current_24h: { overall: 'Sunny', synopsis, temp_range: '20–25°C', rh_range: '40–60%', wind: '1-4 m/s', precip_probability: 10 }, + } })); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + const tile = page.locator('#weatherForecastBox'); + await expect(page.locator('#forecastOverall')).toHaveText('Sunny.'); + const original = await tile.boundingBox(); + const longText = 'Today (NWS · original units): ' + 'Sunny, with a high near 80. West wind 5 to 10 mph. '.repeat(15); + synopsis = longText; + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page.locator('#forecastOverall')).toHaveText(longText.trim()); + const changed = await tile.boundingBox(); + expect(changed.width).toBe(original.width); + expect(changed.height).toBe(original.height); + await page.locator('#forecastOverall').focus(); + await page.keyboard.press('End'); + await expect.poll(() => page.locator('#forecastOverall').evaluate(el => el.scrollTop)).toBeGreaterThan(0); +}); diff --git a/tests/weather-forecast.spec.js b/tests/weather-forecast.spec.js index 1013a24..7f36883 100644 --- a/tests/weather-forecast.spec.js +++ b/tests/weather-forecast.spec.js @@ -15,6 +15,10 @@ for (const budget of cardBudgets) { const card = page.locator('.forecast-panel'); await expect(card).not.toContainText('Updated from'); await expect(card.getByRole('button', { name: '6-day details' })).toHaveCount(0); + await expect(card.locator('.forecast-synopsis')).toContainText('then rain around 8 PM'); + await expect(card.locator('.forecast-hour:visible').first()).toContainText('RH 55%'); + await expect(card.locator('.forecast-hour:visible').first()).toContainText(units === 'Metric' ? 'Wind 14 km/h' : 'Wind 9 mph'); + const originalHeight = (await card.boundingBox()).height; const days = card.locator('.forecast-day'); await expect(days).toHaveCount(6); for (const day of await days.all()) { @@ -30,6 +34,13 @@ for (const budget of cardBudgets) { await card.screenshot({ path: testInfo.outputPath(`forecast-${units}.png`) }); await card.getByRole('button', { name: 'Show next forecast hour' }).click(); await expect(card.locator('[data-hourly-status]')).toHaveText('Hours 2–9 of 24'); + await page.goto(`/weather-forecast?units=${units}&native=true`); + const synopsis = card.locator('.forecast-synopsis'); + await expect(synopsis).toContainText('This Afternoon (NWS · original units)'); + expect((await card.boundingBox()).height).toBe(originalHeight); + await synopsis.focus(); + await page.keyboard.press('End'); + await expect.poll(() => synopsis.evaluate(el => el.scrollTop)).toBeGreaterThan(0); } }); } diff --git a/ui_static/weather_forecast/app.css b/ui_static/weather_forecast/app.css index e2b1116..ecc7617 100644 --- a/ui_static/weather_forecast/app.css +++ b/ui_static/weather_forecast/app.css @@ -526,17 +526,20 @@ body.caelus-theme-preview-mode .dashboard-return { opacity: 0; pointer-events: n .current-reading-list strong { margin-top: 0.25rem; font-size: 0.86rem; } .readings-footer { width: 100%; margin-top: auto; padding: 0.85rem 0 0.1rem; border-top: 1px solid var(--line); box-sizing: border-box; } .provider-label { color: var(--accent-2); font-size: 0.62rem; letter-spacing: 0.08em; text-transform: uppercase; } -.forecast-summary { display: grid; grid-template-columns: 7rem 1fr; align-items: center; gap: 1rem; min-height: 8.4rem; border-bottom: 1px solid var(--line); } -.forecast-main-icon { font-size: 4.6rem; text-align: center; line-height: 0; } +.forecast-summary { display: grid; grid-template-columns: 3rem minmax(0, 1fr); align-items: center; gap: 1rem; min-height: 8.4rem; border-bottom: 1px solid var(--line); } +.forecast-main-icon { font-size: 2.6rem; text-align: center; line-height: 0; } .forecast-icon { width: 1em; height: 1em; overflow: visible; } .forecast-icon-sun { fill: #ffd23f; stroke: #ffbd20; stroke-width: 3; stroke-linecap: round; } .forecast-icon-cloud { fill: #edf5f7; stroke: #bad2d8; stroke-width: 1.5; stroke-linejoin: round; } .forecast-icon-rain, .forecast-icon-fog { fill: none; stroke: #52c7ff; stroke-width: 4; stroke-linecap: round; } .forecast-icon-snow { fill: none; stroke: #dff7ff; stroke-width: 2.5; stroke-linecap: round; } .forecast-icon-bolt { fill: #ffd23f; stroke: #ffad1f; stroke-width: 1; stroke-linejoin: round; } -.forecast-summary > div { display: flex; justify-content: space-around; gap: 0.5rem; } -.forecast-summary span span, .forecast-summary > div > span { color: var(--muted); font-size: 0.62rem; text-align: center; } -.forecast-summary strong { display: block; color: var(--ink); font: 500 1.45rem Georgia, serif; } +.forecast-summary-content { min-width: 0; } +.forecast-ranges { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.3rem; } +.forecast-ranges > span { color: var(--muted); font-size: 0.52rem; text-align: center; } +.forecast-ranges strong { display: block; color: var(--ink); font-size: 0.68rem; } +.forecast-synopsis { height: 3.4rem; overflow: auto; margin: 0.6rem 0 0; font-size: 0.66rem; line-height: 1.3; } +.forecast-synopsis:focus-visible { outline: 1px solid var(--accent-2); } .hourly-carousel { display: grid; grid-template-columns: 1.15rem minmax(0, 1fr) 1.15rem; align-items: stretch; gap: 0.25rem; margin-top: 1rem; } .hourly-page-previous { grid-column: 1; } .hourly-strip { grid-column: 2; display: grid; grid-template-columns: repeat(8, 1fr); gap: 0.35rem; } @@ -544,13 +547,14 @@ body.caelus-theme-preview-mode .dashboard-return { opacity: 0; pointer-events: n .hourly-page-button { min-width: 0; padding: 0; border: 0; border-radius: 0.5rem; background: rgba(255,255,255,.05); color: var(--muted); font-size: 0.72rem; line-height: 1; cursor: pointer; } .hourly-page-button:hover, .hourly-page-button:focus-visible { background: rgba(255,255,255,.12); color: var(--ink); outline: 1px solid var(--accent-2); } .hourly-page-status { margin: 0.35rem 1.4rem 0; color: var(--muted); font-size: 0.48rem; letter-spacing: 0.04em; text-align: center; } -.forecast-hour { min-width: 0; text-align: center; padding: 0.55rem 0.2rem; border-left: 1px solid var(--line); } +.forecast-hour { min-width: 0; text-align: center; padding: 0.2rem 0.2rem; border-left: 1px solid var(--line); } .forecast-hour:first-child { border-left: 0; } .forecast-hour time, .forecast-hour strong, .forecast-hour small, .forecast-hour > span { display: block; } .forecast-hour time { color: var(--muted); font-size: 0.54rem; } -.forecast-hour > span { margin: 0.45rem 0; font-size: 1.65rem; } +.forecast-hour > span { margin: 0.1rem 0; font-size: 1.4rem; } .forecast-hour strong { font-size: 0.8rem; } .forecast-hour small { margin-top: 0.22rem; color: #6dc8ff; font-size: 0.53rem; } +.forecast-hour .hour-stat { color: var(--muted); margin-top: 0; font-size: 0.5rem; } .forecast-meta { margin: 0.65rem 0 0; color: var(--muted); font-size: 0.58rem; text-align: right; } .future-heading { display: flex; align-items: baseline; justify-content: space-between; margin-top: 0.9rem; padding-top: 0.8rem; border-top: 1px solid var(--line); } .future-heading span { color: var(--warm); font-size: 0.62rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; } diff --git a/ui_templates/weather_forecast/index.html b/ui_templates/weather_forecast/index.html index 34c38cb..2f6b22a 100644 --- a/ui_templates/weather_forecast/index.html +++ b/ui_templates/weather_forecast/index.html @@ -72,11 +72,17 @@

{{ location.name or 'Your weather, alive.' }}

Today’s forecast

{{ forecast.condition }}

{{ forecast.provider_label }}
{% if forecast.ok %} -
{{ weather_icon(forecast.icon_key, forecast.condition) }}
{{ reading(forecast.high, forecast.temperature_unit) }} high{{ reading(forecast.low, forecast.temperature_unit) }} low{{ reading(forecast.precip_probability, '%') }} {{ forecast.precip_label }}
+
+ {{ weather_icon(forecast.icon_key, forecast.condition) }} +
+
{{ reading(forecast.low, forecast.temperature_unit) }}–{{ reading(forecast.high, forecast.temperature_unit) }}Temperature{{ forecast.rh_range }}RH{{ forecast.wind_range }}Wind{{ reading(forecast.precip_probability, '%') }} {{ forecast.precip_label }}
+

{{ forecast.synopsis }}

+
+
{% set hour_count = forecast.hours | length %}

Hours 1–{{ 8 if hour_count > 8 else hour_count }} of {{ hour_count }}

@@ -91,7 +97,7 @@

{{ location.name or 'Your weather, alive.' }}

{{ day.temp_range }} RH {{ day.rh_range }} Wind {{ day.wind_range }} - {{ 'Snow' if day.precip_label == 'Snow chance' else 'Rain' }} {{ reading(day.precip_probability, '%') }} + {{ 'Snow' if day.precip_label == 'Snow chance' else 'Rain' }} {{ reading(day.precip_probability, '%') }}
{% endfor %}