diff --git a/assets/screenshots/sai_mobile_dashboard.png b/assets/screenshots/sai_mobile_dashboard.png
new file mode 100644
index 0000000..9412a3f
Binary files /dev/null and b/assets/screenshots/sai_mobile_dashboard.png differ
diff --git a/assets/screenshots/sai_mobile_graph_selectors.png b/assets/screenshots/sai_mobile_graph_selectors.png
new file mode 100644
index 0000000..b915ad7
Binary files /dev/null and b/assets/screenshots/sai_mobile_graph_selectors.png differ
diff --git a/assets/screenshots/sai_mobile_landscape_graph.png b/assets/screenshots/sai_mobile_landscape_graph.png
new file mode 100644
index 0000000..43360b0
Binary files /dev/null and b/assets/screenshots/sai_mobile_landscape_graph.png differ
diff --git a/assets/screenshots/sai_mobile_portrait_graph.png b/assets/screenshots/sai_mobile_portrait_graph.png
new file mode 100644
index 0000000..78d6dc5
Binary files /dev/null and b/assets/screenshots/sai_mobile_portrait_graph.png differ
diff --git a/assets/screenshots/sai_mobile_settings_1.png b/assets/screenshots/sai_mobile_settings_1.png
new file mode 100644
index 0000000..789c9dc
Binary files /dev/null and b/assets/screenshots/sai_mobile_settings_1.png differ
diff --git a/assets/screenshots/sai_mobile_settings_2.png b/assets/screenshots/sai_mobile_settings_2.png
new file mode 100644
index 0000000..39e414d
Binary files /dev/null and b/assets/screenshots/sai_mobile_settings_2.png differ
diff --git a/assets/screenshots/sai_mobile_settings_4.png b/assets/screenshots/sai_mobile_settings_4.png
new file mode 100644
index 0000000..93a16a5
Binary files /dev/null and b/assets/screenshots/sai_mobile_settings_4.png differ
diff --git a/assets/screenshots/sai_mobile_settings_5.png b/assets/screenshots/sai_mobile_settings_5.png
new file mode 100644
index 0000000..a800a77
Binary files /dev/null and b/assets/screenshots/sai_mobile_settings_5.png differ
diff --git a/docs/user_guide.md b/docs/user_guide.md
index 3134fb7..a344604 100644
--- a/docs/user_guide.md
+++ b/docs/user_guide.md
@@ -46,8 +46,9 @@ interface:
select it to expand the content.
- A **down-pointing triangle (▼)** means that the content is expanded; select it
to collapse the content.
-- General Settings sections, integration blocks, and other expandable panes use
- these same right/down disclosure triangles.
+- On desktop, General Settings sections, integration blocks, and other expandable
+ panes use these same right/down disclosure triangles. Phone settings use the
+ menu navigation described below.
- A dashboard Sensor Group shows a disclosure triangle only when it has more
than six Sensor Tiles. The first six Sensor Tiles remain visible when the
group is collapsed.
@@ -57,8 +58,9 @@ interface:
The **three horizontal bars** beside a Sensor Group heading open its **Move up**
and **Move down** commands. Expand and collapse controls change only what is
visible; they do not change sensor settings, switch state, or saved selections.
-Reordering sensor groups does not move the Sensorius overview graphic from the
-bottom of the dashboard.
+Reordering sensor groups does not move the Sensorius AI graphic from the
+top of the dashboard. Tap or click that graphic to refresh the app, including
+when using an iPhone Home Screen web app.
Dashboard data comes from the latest values in the live runtime cache and from the local database. If a device is offline, the latest stored reading may still be visible, but the online/offline state comes from live device status, MQTT heartbeat or availability messages, and recent packets.
@@ -216,6 +218,18 @@ General Settings contains hub-level settings, notifications, system-wide
automations, device onboarding, integrations, locations, firmware updates, and
maintenance tools.
+On phones, General, Sensor, and Switch Settings open with a list of menu items.
+Tap a row with a **›** to open its view. Sections that expand on desktop become
+another menu level on mobile, including theme selections inside Display.
+Use **< Back** at the top to return one level; entered values stay in the form.
+Back does not save changes. **Save** stays with its original section, and the
+Display Save button remains available while choosing a theme. The **×** closes
+the dialog. Opening settings again starts at the menu.
+
+Phone settings use a screen-width view while open. Closing the dialog restores
+the dashboard's existing scaling. Desktop settings keep their side menus and
+expand/collapse sections.
+
### General Settings Pane
The main pane contains six expandable sections. Open one to review or change
@@ -1067,8 +1081,13 @@ Switch event overlays appear as vertical lines. The legend shows which colors me
### Sensorius Graphum Controls
-The graph button opens the full-screen workspace directly. Its scrollable left
-pane contains:
+The graph button opens the full-screen workspace directly. On phones in portrait
+or landscape, the chart spans the screen width at the top, followed by Time Range
+and then Sensor Metrics and Switch selections. Scroll down to reach the controls;
+all available time ranges are shown without a separate clipped selector pane.
+Closing Graphum restores the dashboard's existing scaling.
+
+On desktop, the controls remain in the scrollable left pane:
- **Time range**: select **1Hr**, **3Hr**, **6Hr**, **12Hr**, **24Hr**,
**3Day**, **7Day**, **14Day**, **30Day**, **60Day**, or **90Day**. Available
@@ -1170,11 +1189,11 @@ The hourly strip presents the next 24 hours as one-hour forecasts, with eight
hours visible at a time. Use the narrow left and right controls to move the
window one hour; each control disappears when its edge is reached. Hourly and
daily forecasts show rain or snow chance percentages, while condition icons
-identify rain or snow when the provider predicts a precipitation type. Select
-**6-day details** under **Looking Ahead** to open the detailed outlook with
-daily conditions, temperature, relative humidity, wind, and rain or snow chance.
-
-
+identify rain or snow when the provider predicts a precipitation type.
+**Looking Ahead** shows each day's date, weather icon, temperature minimum–maximum,
+relative humidity minimum–maximum, wind minimum–maximum, and rain or snow chance
+directly in the daily tiles. Temperature and wind follow the selected display
+units. These details fit within the existing forecast card.
## Biodynamic Calendar Features
@@ -1241,6 +1260,108 @@ generate summaries for their default day. For the current day, the summary may
include a **24hr Forecast** section if weather forecast data is enabled in
General Settings.
+## Mobile Operations
+
+Open Sensorius in your phone's browser or from its Home Screen shortcut, using
+the same hub address you normally use. The examples below show an iPhone;
+device names, available metrics, settings, and time ranges depend on your
+installation. The screenshots show selected views, not every available option.
+Tap an image to open it at full size.
+
+### Mobile Dashboard
+
+
+
+
+
+The dashboard is the starting point for readings, settings, and history.
+
+- Tap the **Sensorius AI image** at the top to refresh the app. Save any pending
+ settings changes before refreshing.
+- Tap the **graph icon to the left** of the image to open Sensorius Graphum.
+ Tap the **gear to the right** to open General Settings. A gear beside a sensor
+ or switch opens settings for that device.
+- Swipe up to see sensor groups and switch tiles farther down the dashboard.
+ Use **Show Device by Location** to narrow the display, or choose
+ **All Locations** to restore the complete view.
+- Tap a sensor group's **disclosure triangle** to reveal or hide its additional
+ metrics. Tap a Sensor Tile to cycle its available graph and gauge views;
+ save a preferred display style in Sensor Settings to make it persistent.
+- Use the named buttons on the overview tiles to open Caelus Forecast, the
+ Biodynamic Calendar, the Lunar Calendar, or the 29 Day Graph. These are
+ separate views from Graphum; use their close or return control to come back.
+
+### Mobile Settings
+
+The following row illustrates **Settings → General Settings → Display →
+Sensorius Dashboard Theme**. The same menu-and-Back navigation is used for
+Sensor Settings and Switch Settings, with options appropriate to each device.
+
+
+
+ 1. Settings menu
+ 2. General Settings
+ 3. Display
+ 4. Theme selection
+
+
+
+1. Tap a settings gear, then tap the desired **menu row with a ›**. Some rows
+ open entry fields immediately; others open another menu. Only the selected
+ view is displayed, so the previous menu is no longer beside or above it.
+2. Continue through any submenus. For example, choose **General Settings**,
+ then **Display** to change units, the metric set, or the display style.
+ Choose **Sensorius Dashboard Theme** within Display to see its theme choices.
+ Other entries lead to automations, device management, integrations, and
+ advanced settings; use the relevant sections of this guide for those controls.
+3. Swipe up within the view to reach fields, choices, and buttons below the
+ visible area. Tap a drop-down to see its available values. If the phone's
+ keyboard covers a control, finish the entry and dismiss the keyboard.
+4. Tap the section's **Save** button to apply its changes. Save remains with
+ the section being edited; the Display Save button is also available inside
+ a theme selection view. Save each section you change before moving on.
+5. Tap **< Back** at the top to return **one level**. From the theme choices,
+ Back returns to Display; subsequent taps return to General Settings and then
+ the settings menu. Back is navigation, not Save.
+6. Tap the **circled ×** to close settings and return to the dashboard. Closing
+ is not Save either. Reopening settings starts at its menu.
+
+### Mobile Graphum
+
+Graphum uses the full screen width in both orientations. Its scroll order is
+**chart → Time range and Astral → Sensors & metrics → Switches**. The landscape
+example shows the chart area; its selectors are farther down the same view.
+
+
+
+ Portrait chart
+ Landscape chart
+ Selectors below the chart
+
+
+
+1. Open Graphum using the dashboard's **graph icon**. Swipe up past the chart
+ to reach the controls, including in landscape. The chart and controls scroll
+ together rather than covering each other.
+2. Choose a **Time range**. Options run from **1Hr** through longer day ranges;
+ the longest available ranges depend on database retention, so your choices
+ may differ from the screenshot. Choose **Custom** to enter both a start and
+ an end date and time. Use **Astral** to add Sun, Moon, or Sun & Moon positions,
+ or select **None** to omit that panel.
+3. Under **Sensors & metrics**, tap a device's **+** to reveal its metrics,
+ then check the measurements to graph. Tap **−** to collapse the list without
+ clearing its checked metrics. Scroll farther down for other devices.
+4. To compare switching with sensor history, expand a device under **Switches**
+ and check a channel. Switch selections add ON/OFF transition markers to a
+ sensor graph. Metrics and switch channels share a limit of **four selections**;
+ uncheck an item to make room when the counter reaches four.
+5. The chart updates automatically when selections or the time range change.
+ Swipe down to return to the chart and check the selection labels above it.
+ Rotate the phone for a wider landscape graph; you do not need to select the
+ metrics again.
+6. Tap the **circled ×** at the top to return to the dashboard. The dashboard
+ returns to its usual scaling when Graphum closes.
+
## Good Operating Habits
- Use names and locations that match how people talk about the garden or farm.
diff --git a/docs/user_guide.pdf b/docs/user_guide.pdf
index 113fdb2..8eec3ee 100644
Binary files a/docs/user_guide.pdf and b/docs/user_guide.pdf differ
diff --git a/sensorius/__init__.py b/sensorius/__init__.py
index 6e44693..fcaee48 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.251.13"
+__version__ = "v0.26.252.4"
diff --git a/sensorius/saiHtml.py b/sensorius/saiHtml.py
index e70d84b..3cce294 100644
--- a/sensorius/saiHtml.py
+++ b/sensorius/saiHtml.py
@@ -1202,10 +1202,12 @@ def _db_label_map_for_switch(sw_id: str) -> dict[str, str]:
yield from render_graph_modal(switch_installed=switch_installed, gauge_config=gauge_config)
# global assets for templates
yield f" "
+ yield f" "
+ yield f""
yield f""
yield f""
yield f""
- yield ""
+ yield f""
body_style = f" style='{html_escape(dashboard_custom_theme_style)}'" if dashboard_custom_theme_style else ""
yield f""
yield (
@@ -1698,7 +1700,9 @@ def _status_color_hex(status: str) -> str:
yield " fill='none' stroke='blue' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/>"
yield " "
yield ""
+ yield ""
yield f" "
+ yield " "
yield ""
@@ -6442,6 +6446,7 @@ def _format_stat_value(stat_value):
yield "};"
yield ""
yield "window.closeSystemSettingsModal = function(){"
+ yield " window.setMobileWorkspaceViewport?.(document.getElementById('setupPiModal'), false);"
yield " const ids = ["
yield " 'setupPiModal',"
yield " 'ha-settings-overlay',"
@@ -6764,6 +6769,7 @@ def _format_stat_value(stat_value):
yield " const modal = modalEl || document.getElementById('switchSettingsModal');"
yield " if (!modal) return;"
yield " const btnSettings = modal.querySelector('#switchMenuSettings');"
+ yield " window.initMobileSettings?.(modal);"
yield " const btnStats = modal.querySelector('#switchMenuStatistics');"
yield " const btnBacks = modal.querySelectorAll('[data-switch-pane-target=\"settings\"]');"
yield " const paneSettings = modal.querySelector('#switchSettingsPane');"
@@ -7123,6 +7129,7 @@ def _format_stat_value(stat_value):
yield " if (!modalId) return;"
yield " const modal = document.getElementById(modalId);"
yield " if (!modal) return;"
+ yield " window.setMobileWorkspaceViewport?.(modal, false);"
yield " const backdrop = modal.closest('.modal-backdrop');"
yield " if (backdrop && backdrop.parentNode) {"
yield " backdrop.parentNode.removeChild(backdrop);"
@@ -8421,12 +8428,32 @@ def render_graph_modal(switch_installed=None, gauge_config=None):
margin-top:.35rem;
}
#graphLoadingState{ display:none; color:var(--dashboard-card-text); opacity:.76; font-size:.8rem; }
- @media (max-width: 760px){
+ @media (max-width: 760px), (max-device-width: 760px), (pointer: coarse) and (max-height: 600px){
+ .fullscreen-graph-header{ min-height:56px; padding:.4rem .65rem; }
+ .fullscreen-graph-title{ font-size:1.25rem; }
.fullscreen-graph-body{ flex-direction:column; overflow:auto; }
- .fullscreen-graph-controls{ flex:0 0 auto; width:100%; max-height:43dvh; border-right:0; border-bottom:1px solid var(--dashboard-card-border); }
- .fullscreen-graph-main{ min-height:54dvh; }
+ .fullscreen-graph-controls{
+ flex:0 0 auto; width:100%; min-width:0; max-height:none; overflow:visible;
+ border-right:0; border-top:1px solid var(--dashboard-card-border);
+ }
+ .fullscreen-graph-main{
+ order:-1; flex:0 0 auto; width:100%; min-height:0; padding:.5rem;
+ box-sizing:border-box;
+ }
.fullscreen-graph-summary{ align-items:flex-start; flex-direction:column; gap:.35rem; }
+ #fullscreen_graph_stack{ flex:0 0 auto; }
+ #fullscreen_graph_container #fullscreen_data_panel{
+ flex:0 0 auto; height:clamp(240px, 50dvh, 480px); min-height:0;
+ box-sizing:border-box; overflow:hidden;
+ }
+ #fullscreen_astral_panel{ flex:0 0 140px; box-sizing:border-box; }
+ #fullscreen_graph, #fullscreen_astral_graph{ margin:0; }
#fullscreenSelectedChips{ justify-content:flex-start; }
+ .fullscreen-time-grid span{ min-height:44px; }
+ #custom_time_inputs input, .fullscreen-astral-field select{ font-size:16px; }
+ }
+ @media (orientation: landscape) and (pointer: coarse) and (max-height: 600px){
+ .fullscreen-time-grid{ grid-template-columns:repeat(6, minmax(0, 1fr)); }
}
"""
@@ -10165,6 +10192,7 @@ def _range_visible(value: str) -> bool:
function closeFullscreenGraph(){
const cont = document.getElementById('fullscreen_graph_container');
if (cont) cont.style.display = 'none';
+ window.setMobileWorkspaceViewport?.(cont, false);
if (cont) cont.classList.remove('has-astral');
fullscreenGraphRequestToken += 1;
if(fullscreenGraphRefreshTimer) window.clearTimeout(fullscreenGraphRefreshTimer);
@@ -10194,7 +10222,10 @@ def _range_visible(value: str) -> bool:
try {
const workspace = document.getElementById('fullscreen_graph_container');
if (workspace){
+ window.setMobileWorkspaceViewport?.(workspace, true,
+ '(max-width: 760px), (max-device-width: 760px), (pointer: coarse) and (max-height: 600px)');
workspace.style.display = 'flex';
+ workspace.querySelector('.fullscreen-graph-body').scrollTop = 0;
if (window.ensureButtonTooltips) window.ensureButtonTooltips(workspace);
await window.ModalBusyCursor.untilPaint();
await initFullscreenGraphWorkspace();
diff --git a/sensorius/saiWeatherForecastApp.py b/sensorius/saiWeatherForecastApp.py
index 5ba1b7b..0d7aa62 100644
--- a/sensorius/saiWeatherForecastApp.py
+++ b/sensorius/saiWeatherForecastApp.py
@@ -220,8 +220,8 @@ def _forecast_temperature_range(value: object, unit_system: object) -> str:
return text
-def _forecast_wind(value: object, unit_system: object) -> str:
- """Format a canonical dual-unit wind summary in the selected display unit."""
+def _forecast_wind(value: object, unit_system: object, *, include_description: bool = True) -> str:
+ """Format a canonical wind summary or numeric range in the selected unit."""
text = str(value or "").strip()
if not text or text == "--":
return "--"
@@ -248,8 +248,8 @@ def _forecast_wind(value: object, unit_system: object) -> str:
high = round(float(match.group(2)) * 3.6)
selected = f"{low}-{high} km/h"
if not selected:
- return text
- return f"{descriptor}\n{selected}" if descriptor and descriptor != measurement else selected
+ return text if include_description else "--"
+ return f"{descriptor}\n{selected}" if include_description and descriptor and descriptor != measurement else selected
def _format_hour_label(value: object) -> str:
@@ -363,6 +363,7 @@ def build_weather_display_forecast(
"temp_range": _forecast_temperature_range(raw.get("temp_range"), display_unit_system),
"rh_range": str(raw.get("rh_range") or "--"),
"wind": _forecast_wind(raw.get("wind"), display_unit_system),
+ "wind_range": _forecast_wind(raw.get("wind"), display_unit_system, include_description=False),
"precipitation_mm": _safe_float(raw.get("precip_mm")) or 0.0,
"precip_probability": (
round(probability) if (probability := _safe_float(raw.get("precip_probability"))) is not None else None
diff --git a/sensorius/saiWebRoutes.py b/sensorius/saiWebRoutes.py
index 9f42337..337cd33 100644
--- a/sensorius/saiWebRoutes.py
+++ b/sensorius/saiWebRoutes.py
@@ -4637,6 +4637,8 @@ def _format_hhmm(dt_obj: datetime | None) -> str:
fragment_parts: list[str] = []
fragment_parts.append(f" ")
+ fragment_parts.append(f" ")
+ fragment_parts.append(f"")
fragment_parts.append(f"")
fragment_parts.append(f"")
fragment_parts.append(system_modal_html)
@@ -4653,6 +4655,7 @@ def _format_hhmm(dt_obj: datetime | None) -> str:
html_parts: list[str] = []
html_parts.append("")
html_parts.append("General Settings ")
+ html_parts.append(' ')
html_parts.append("")
html_parts.append(fragment_html)
html_parts.append("")
@@ -11539,8 +11542,11 @@ def _add_offset(
page: list[str] = []
page.append("")
page.append("Edit Sensor ")
+ page.append(' ')
page.append(f" ")
- page.append("")
+ page.append(f" ")
+ page.append(f"")
+ page.append(f"")
page.append("")
page.append("")
page.append("
")
@@ -13497,8 +13503,11 @@ async def edit_switch_page(
# ---- full-page fallback (keeps existing behavior & JS wiring) ----
page: list[str] = []
page.append("Edit Switch ")
+ page.append(' ')
# ensure app.css is loaded so modal styles look correct
page.append(f" ")
+ page.append(f" ")
+ page.append(f"")
page.append("")
page.append("
")
modal_json = json.dumps(modal_html).replace("", "<\\/")
diff --git a/testApparatus/playwright_host.py b/testApparatus/playwright_host.py
index 978a38b..2d6a2c0 100644
--- a/testApparatus/playwright_host.py
+++ b/testApparatus/playwright_host.py
@@ -9,6 +9,7 @@
import os
import tempfile
from pathlib import Path
+from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
@@ -96,3 +97,61 @@ def biodynamic_calendar(request: Request):
"runtime_instance_id": "playwright",
},
)
+
+
+@app.get("/edit-system", response_class=HTMLResponse)
+def system_settings() -> HTMLResponse:
+ """Render the actual settings dialog without production settings or hardware."""
+ return HTMLResponse(templates.get_template("modals/system_settings.html").render(
+ app_name_long="Sensorius AI", app_version="playwright", custom_themes={},
+ display_style="Gauge", metric_set="Pick 6", gauge_size="Small",
+ ))
+
+
+@app.get("/edit-sensor", response_class=HTMLResponse)
+def sensor_settings() -> HTMLResponse:
+ """Render a sensor's real settings and calibration panes for mobile checks."""
+ return HTMLResponse(templates.get_template("modals/sensor_settings.html").render(
+ sensor_id="aht-pr-check", current_metrics=["Temperature"] + [""] * 5,
+ metric_options=["", "Temperature"], settings={}, location="Greenhouse",
+ ))
+
+
+@app.get("/edit-switch", response_class=HTMLResponse)
+def switch_settings() -> HTMLResponse:
+ """Render a remote switch dialog without contacting a device."""
+ return HTMLResponse(templates.get_template("modals/switch_settings.html").render(
+ switch_id="switch-pr-check", settings={"Switch": {"SWITCH_LOCATION": "Greenhouse"}},
+ channels=[{"index": 0, "label": "Pump"}], channel_indices=[0],
+ ))
+
+
+@app.get("/weather-forecast", response_class=HTMLResponse)
+def weather_forecast(request: Request, units: str = "Metric"):
+ """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},
+ "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",
+ } for hour in range(24)],
+ "days": [{
+ "date": (start + timedelta(days=day)).date().isoformat(),
+ "label": (start + timedelta(days=day)).strftime("%a %b %d"),
+ "forecast": "Partly cloudy", "temp_range": "17.8-30.0°C / 64-86°F",
+ "rh_range": "35-85%", "wind": "Mostly light\n1-8 m/s / 2-18 mph",
+ "precip_probability": 59,
+ } for day in range(1, 7)],
+ }, units)
+ return templates.TemplateResponse(request, "weather_forecast/index.html", {
+ "settings": {"theme": "pollinator", "theme_class": "pollinator"},
+ "location": {"name": "Greenhouse", "latitude": 39.7, "longitude": -104.9},
+ "latest": {}, "moon": {"updated_at": start.isoformat()},
+ "forecast": forecast, "app_version": "playwright",
+ })
diff --git a/testApparatus/test_integrated_weather_forecast.py b/testApparatus/test_integrated_weather_forecast.py
index 9cd0fc0..412f806 100644
--- a/testApparatus/test_integrated_weather_forecast.py
+++ b/testApparatus/test_integrated_weather_forecast.py
@@ -186,6 +186,7 @@ def test_weather_display_forecast_uses_canonical_sensorius_contract():
assert payload["days"][0]["temp_range"] == "64-82°F"
assert payload["days"][0]["precip_probability"] == 42
assert payload["days"][0]["precip_label"] == "Rain chance"
+ assert payload["days"][0]["wind_range"] == "--"
def test_weather_display_forecast_uses_metric_units_when_selected():
@@ -208,6 +209,9 @@ def test_weather_display_forecast_uses_metric_units_when_selected():
assert payload["hours"][0]["precipitation_unit"] == "mm"
assert payload["days"][0]["temp_range"] == "18.0-28.0°C"
assert payload["days"][0]["wind"] == "Mostly light\n4-14 km/h"
+ assert payload["days"][0]["wind_range"] == "4-14 km/h"
+ imperial = weather_app.build_weather_display_forecast(source, "Imperial")
+ assert imperial["days"][0]["wind_range"] == "2-9 mph"
def test_snow_forecast_uses_snow_chance_label():
@@ -576,7 +580,11 @@ async def _fake_forecast(*_args, **_kwargs):
assert "data-hourly-next" in page.text
assert ">40% Rain chance" in page.text
assert "40% rain chance" in page.text
- assert "42% rain chance" in page.text
+ assert "Rain 42%" in page.text
+ assert "RH 30-60%" in page.text
+ assert "Wind --" in page.text
+ assert "Updated from" not in page.text
+ assert "data-open-forecast" not in page.text
assert "PoP" not in page.text
assert 'class="forecast-icon forecast-icon--rain"' in page.text
assert 'class="forecast-icon forecast-icon--sunny"' in page.text
diff --git a/tests/mobile-graph.spec.js b/tests/mobile-graph.spec.js
new file mode 100644
index 0000000..6d5e992
--- /dev/null
+++ b/tests/mobile-graph.spec.js
@@ -0,0 +1,75 @@
+import { test, expect } from '@playwright/test';
+
+async function graphFixture(page) {
+ await page.route('https://cdn.jsdelivr.net/**', route => route.fulfill({ contentType: 'application/javascript', body: '' }));
+ await page.route('**/device-locations', route => route.fulfill({ json: [] }));
+ await page.route('**/sensor-ids', route => route.fulfill({ json: ['aht-pr-check'] }));
+ await page.route('**/sensor-metrics?*', route => route.fulfill({ json: ['Temperature', 'Rel-Humidity', 'Ambient VPD', 'Dew Point'] }));
+ await page.route('**/graph-data?*', route => route.fulfill({ json: { no_data: true } }));
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+}
+
+async function assertMobileGraph(page) {
+ const geometry = await page.locator('#fullscreen_graph_container').evaluate(root => {
+ const box = selector => root.querySelector(selector).getBoundingClientRect().toJSON();
+ const controls = root.querySelector('.fullscreen-graph-controls');
+ return {
+ root: root.getBoundingClientRect().toJSON(), chart: box('#fullscreen_data_panel'),
+ main: box('.fullscreen-graph-main'), controls: box('.fullscreen-graph-controls'),
+ canvas: box('#fullscreen_graph'), time: box('.fullscreen-time-grid'),
+ metrics: box('#fullscreenSensorOptions'), clipped: controls.scrollHeight > controls.clientHeight + 1,
+ overflow: root.querySelector('.fullscreen-graph-body').scrollWidth > root.clientWidth + 1,
+ };
+ });
+ expect(geometry.main.bottom).toBeLessThanOrEqual(geometry.controls.top + 1);
+ expect(geometry.time.bottom).toBeLessThan(geometry.metrics.top);
+ expect(geometry.chart.width).toBeGreaterThan(geometry.root.width - 24);
+ expect(geometry.canvas.bottom).toBeLessThanOrEqual(geometry.chart.bottom);
+ expect(geometry.chart.height).toBeGreaterThanOrEqual(240);
+ expect(geometry.clipped).toBe(false);
+ expect(geometry.overflow).toBe(false);
+ const labels = page.locator('.fullscreen-time-grid label');
+ await expect(labels).toHaveCount(12);
+ for (const label of await labels.all()) {
+ await label.tap();
+ await expect(label.locator('input')).toBeChecked();
+ }
+ await expect(page.locator('#start_time')).toBeVisible();
+ const metric = page.locator('.fullscreen-metric-checkbox').last();
+ await metric.check();
+ await expect(metric).toBeChecked();
+ await metric.uncheck();
+}
+
+for (const viewport of [{ width: 390, height: 844 }, { width: 844, height: 390 }]) {
+ test(`phone Graphum shows chart then every selector at ${viewport.width}x${viewport.height}`, async ({ browser, baseURL }, testInfo) => {
+ const page = await browser.newPage({ baseURL, viewport, isMobile: true, hasTouch: true });
+ try {
+ await graphFixture(page);
+ const dashboardWidth = await page.evaluate(() => innerWidth);
+ await page.getByRole('link', { name: 'Full Screen Graphs' }).tap();
+ await expect(page.locator('.fullscreen-metric-checkbox')).toHaveCount(4);
+ await assertMobileGraph(page);
+ await page.locator('.fullscreen-time-grid label').filter({ hasText: /^24Hr$/ }).tap();
+ await page.locator('.fullscreen-graph-body').evaluate(element => { element.scrollTop = 0; });
+ await page.screenshot({ path: testInfo.outputPath('mobile-graph.png') });
+ await page.setViewportSize({ width: viewport.height, height: viewport.width });
+ await assertMobileGraph(page);
+ await page.getByRole('button', { name: 'Return to dashboard' }).tap();
+ await page.setViewportSize(viewport);
+ await expect.poll(() => page.evaluate(() => innerWidth)).toBe(dashboardWidth);
+ } finally {
+ await page.close();
+ }
+ });
+}
+
+test('desktop Graphum retains its left selectors and right chart', async ({ page }) => {
+ await graphFixture(page);
+ await page.getByRole('link', { name: 'Full Screen Graphs' }).click();
+ await expect(page.locator('.fullscreen-metric-checkbox')).toHaveCount(4);
+ const controls = await page.locator('.fullscreen-graph-controls').boundingBox();
+ const graph = await page.locator('.fullscreen-graph-main').boundingBox();
+ expect(controls.x + controls.width).toBeLessThanOrEqual(graph.x);
+ expect(controls.y).toBe(graph.y);
+});
diff --git a/tests/mobile-settings.spec.js b/tests/mobile-settings.spec.js
new file mode 100644
index 0000000..b3ebd69
--- /dev/null
+++ b/tests/mobile-settings.spec.js
@@ -0,0 +1,136 @@
+import { test, expect } from '@playwright/test';
+
+test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
+
+test.beforeEach(async ({ page }) => {
+ await page.route('https://cdn.jsdelivr.net/**', route => route.fulfill({ contentType: 'application/javascript', body: '' }));
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+});
+
+async function openGeneral(page) {
+ await page.getByRole('link', { name: 'Open General Settings' }).click();
+ await expect(page.locator('#setupPiModal')).toBeVisible();
+ return page.locator('#setupPiModal');
+}
+
+async function assertFits(root) {
+ expect(await root.evaluate(element => {
+ const body = element.querySelector('.system-settings-body, .modal-body');
+ return body.scrollWidth <= body.clientWidth + 1;
+ })).toBe(true);
+}
+
+test('mobile General Settings drills down, preserves edits, and saves within the section', async ({ page }) => {
+ const dashboardWidth = await page.evaluate(() => innerWidth);
+ const root = await openGeneral(page);
+ const back = root.getByRole('button', { name: '< Back', exact: true });
+ await expect(root.locator('.system-settings-content')).toBeHidden();
+ await expect(back).toBeHidden();
+ await root.getByRole('button', { name: 'General Settings', exact: true }).tap();
+ await expect(root.locator('#systemSettingsMenu')).toBeHidden();
+ await expect(root.locator('#astral_lat')).toBeHidden();
+ await root.locator('summary').filter({ hasText: /^Astral$/ }).tap();
+ await root.locator('#astral_lat').fill('39.75');
+ await expect(root.locator('summary').filter({ hasText: /^Display$/ })).toBeHidden();
+ await assertFits(root);
+ await back.tap();
+ await root.locator('summary').filter({ hasText: /^Astral$/ }).tap();
+ await expect(root.locator('#astral_lat')).toHaveValue('39.75');
+ let saved;
+ await page.route('**/submit-pi-setup', async route => {
+ saved = route.request().postData();
+ await route.fulfill({ json: { status: 'ok' } });
+ });
+ await root.locator('[data-runtime-section="system-astral"]').getByRole('button', { name: 'Save', exact: true }).tap();
+ await expect.poll(() => saved).toContain('39.75');
+ await back.tap();
+ await back.tap();
+ await expect(root.locator('#systemSettingsMenu')).toBeVisible();
+ await root.getByRole('button', { name: 'Edit Locations', exact: true }).tap();
+ await expect(root.locator('#pane-locations')).toBeVisible();
+ await back.tap();
+ await root.getByRole('button', { name: 'Close General Settings' }).tap();
+ await expect.poll(() => page.evaluate(() => innerWidth)).toBe(dashboardWidth);
+ await openGeneral(page);
+ await expect(root.locator('.system-settings-content')).toBeHidden();
+});
+
+test('General Settings section menus fit a narrow phone screen', async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 740 });
+ const root = await openGeneral(page);
+ for (const menuName of ['General Settings', 'Integrations', 'Advanced', 'Add Device']) {
+ await root.getByRole('button', { name: menuName, exact: true }).tap();
+ const summaries = await root.locator('.settings-pane:not([hidden]) details > summary:visible').all();
+ for (const summary of summaries) {
+ await summary.tap();
+ await assertFits(root);
+ const back = root.getByRole('button', { name: '< Back', exact: true });
+ await expect(back).toBeInViewport();
+ await back.tap();
+ }
+ await root.getByRole('button', { name: '< Back', exact: true }).tap();
+ }
+});
+
+test('nested theme views retain the Display Save button and edited values', async ({ page }, testInfo) => {
+ const root = await openGeneral(page);
+ await root.getByRole('button', { name: 'General Settings', exact: true }).tap();
+ await root.locator('summary').filter({ hasText: /^Display$/ }).tap();
+ await root.locator('#metric_set').selectOption('All');
+ await root.locator('summary').filter({ hasText: /^Sensorius Dashboard Theme$/ }).tap();
+ await expect(root.locator('#metric_set')).toBeHidden();
+ await expect(root.locator('[data-runtime-section="system-display"]').getByRole('button', { name: 'Save', exact: true })).toBeVisible();
+ await root.locator('input[name="dashboard_background_theme"][value="flower"]').check();
+ await assertFits(root);
+ await page.screenshot({ path: testInfo.outputPath('mobile-theme.png') });
+ await root.getByRole('button', { name: '< Back', exact: true }).tap();
+ await expect(root.locator('#metric_set')).toHaveValue('All');
+ await expect(root.locator('input[name="dashboard_background_theme"][value="flower"]')).toBeChecked();
+ await root.getByRole('button', { name: '< Back', exact: true }).tap();
+ await root.getByRole('button', { name: '< Back', exact: true }).tap();
+ await page.setViewportSize({ width: 375, height: 812 });
+ await expect(root.locator('.system-settings-content')).toBeHidden();
+ await page.screenshot({ path: testInfo.outputPath('mobile-settings-menu.png') });
+});
+
+test('desktop settings retain their side menu and expansion state across resize', async ({ browser, baseURL }) => {
+ const page = await browser.newPage({ baseURL, viewport: { width: 1280, height: 900 } });
+ try {
+ await page.route('https://cdn.jsdelivr.net/**', route => route.fulfill({ contentType: 'application/javascript', body: '' }));
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+ const root = await openGeneral(page);
+ const display = root.locator('[data-runtime-section="system-display"]');
+ await expect(root.locator('.mobile-settings-nav')).toBeHidden();
+ await display.locator(':scope > summary').click();
+ await root.locator('#metric_set').selectOption('All');
+ await page.setViewportSize({ width: 390, height: 844 });
+ await expect(root.locator('.system-settings-content')).toBeHidden();
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await expect(root.locator('#systemSettingsMenu')).toBeVisible();
+ await expect(display).toHaveAttribute('open');
+ await expect(root.locator('#metric_set')).toHaveValue('All');
+ } finally {
+ await page.close();
+ }
+});
+
+for (const kind of ['Sensor', 'Switch']) {
+ test(`${kind} Settings uses the same menu and Back flow`, async ({ page }, testInfo) => {
+ await page.evaluate(kind => {
+ if (kind === 'Sensor') return window.editSensorSettings('aht-pr-check');
+ return window.editSwitchSettings('switch-pr-check');
+ }, kind);
+ const root = page.locator(`#${kind.toLowerCase()}SettingsModal`);
+ await expect(root.locator('.mobile-settings-content')).toBeHidden();
+ await root.getByRole('button', { name: `${kind} Settings`, exact: true }).tap();
+ await root.locator('#location').fill('South Greenhouse');
+ await assertFits(root);
+ await page.screenshot({ path: testInfo.outputPath(`mobile-${kind.toLowerCase()}-settings.png`) });
+ await root.getByRole('button', { name: '< Back', exact: true }).tap();
+ await root.getByRole('button', { name: `${kind} Info`, exact: true }).tap();
+ await expect(root.locator('#location')).toBeHidden();
+ await root.getByRole('button', { name: '< Back', exact: true }).tap();
+ await root.getByRole('button', { name: `${kind} Settings`, exact: true }).tap();
+ await expect(root.locator('#location')).toHaveValue('South Greenhouse');
+ });
+}
diff --git a/tests/weather-forecast.spec.js b/tests/weather-forecast.spec.js
new file mode 100644
index 0000000..1013a24
--- /dev/null
+++ b/tests/weather-forecast.spec.js
@@ -0,0 +1,35 @@
+import { test, expect } from '@playwright/test';
+
+// Heights measured on the existing card before adding the RH and wind rows.
+const cardBudgets = [{ width: 1440, height: 546 }, { width: 1024, height: 580 }, { width: 390, height: 779 }];
+for (const budget of cardBudgets) {
+ test(`Caelus daily ranges fit the existing card at ${budget.width}px`, async ({ page }, testInfo) => {
+ await page.setViewportSize({ width: budget.width, height: 1000 });
+ await page.route('**/*', route => {
+ const url = new URL(route.request().url());
+ return url.hostname === '127.0.0.1' || url.hostname === 'localhost' ? route.continue() : route.abort();
+ });
+ for (const units of ['Metric', 'Imperial']) {
+ const response = await page.goto(`/weather-forecast?units=${units}`, { waitUntil: 'domcontentloaded' });
+ expect(response.ok()).toBe(true);
+ const card = page.locator('.forecast-panel');
+ await expect(card).not.toContainText('Updated from');
+ await expect(card.getByRole('button', { name: '6-day details' })).toHaveCount(0);
+ const days = card.locator('.forecast-day');
+ await expect(days).toHaveCount(6);
+ for (const day of await days.all()) {
+ await expect(day.locator('time')).toBeVisible();
+ await expect(day.locator('svg')).toBeVisible();
+ await expect(day).toContainText(units === 'Metric' ? '17.8-30.0°C' : '64-86°F');
+ await expect(day).toContainText('RH 35-85%');
+ await expect(day).toContainText(units === 'Metric' ? 'Wind 4-29 km/h' : 'Wind 2-18 mph');
+ await expect(day).toContainText('Rain 59%');
+ expect(await day.evaluate(element => Array.from(element.querySelectorAll('time, strong, small')).every(row => row.scrollWidth <= row.clientWidth + 1))).toBe(true);
+ }
+ expect((await card.boundingBox()).height).toBeLessThanOrEqual(budget.height);
+ 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');
+ }
+ });
+}
diff --git a/ui_static/css/app.css b/ui_static/css/app.css
index 0a4668f..af838f7 100644
--- a/ui_static/css/app.css
+++ b/ui_static/css/app.css
@@ -358,6 +358,17 @@ body.dashboard-page #sensor_header{
border:0;
}
+.dashboard-brand-refresh{
+ display:block;
+ padding:0;
+ border:0;
+ border-radius:12px;
+ background:none;
+ cursor:pointer;
+ touch-action:manipulation;
+}
+.dashboard-brand-refresh:focus-visible{outline:2px solid currentColor;outline-offset:3px}
+
.dashboard-brand-image{
display:block;
width:261px;
diff --git a/ui_static/css/mobile_settings.css b/ui_static/css/mobile_settings.css
new file mode 100644
index 0000000..b86b0b8
--- /dev/null
+++ b/ui_static/css/mobile_settings.css
@@ -0,0 +1,120 @@
+/* Drill-down settings apply only to phone-sized viewports. */
+.mobile-settings-nav, .mobile-settings-chevron { display:none; }
+.mobile-settings-chevron::after { content:'›'; }
+@media (max-width:700px), (max-device-width:700px) {
+ .modal-backdrop:has(.mobile-settings), #setupPiModal.mobile-settings {
+ padding:.35rem;
+ box-sizing:border-box;
+ }
+ #setupPiModal.mobile-settings .system-settings-shell,
+ .modal.mobile-settings {
+ width:100% !important;
+ position:relative !important;
+ left:auto !important;
+ top:auto !important;
+ max-width:100%;
+ height:calc(100dvh - .7rem);
+ max-height:calc(100dvh - .7rem);
+ display:flex;
+ flex-direction:column;
+ overflow:hidden;
+ transform:none !important;
+ }
+ .mobile-settings .mobile-settings-nav {
+ display:flex;
+ align-items:center;
+ gap:.75rem;
+ flex-shrink:0;
+ padding:.35rem .65rem;
+ border-bottom:1px solid var(--line, #d6dfd8);
+ }
+ .mobile-settings-nav button {
+ min-height:44px;
+ padding:.45rem .65rem;
+ border:1px solid var(--line, #d6dfd8);
+ border-radius:8px;
+ background:var(--panel, #fff);
+ color:inherit;
+ font:inherit;
+ cursor:pointer;
+ white-space:nowrap;
+ }
+ .mobile-settings-nav button[hidden] { display:none; }
+ .mobile-settings-title { font-weight:600; overflow-wrap:anywhere; }
+ #setupPiModal.mobile-settings .system-settings-body,
+ .mobile-settings > .modal-body {
+ padding:.65rem;
+ flex:1;
+ min-height:0;
+ overflow:auto;
+ }
+ #setupPiModal.mobile-settings .system-settings-split,
+ .mobile-settings .modal-body > .split {
+ display:block;
+ height:auto;
+ min-height:0 !important;
+ }
+ .mobile-settings .mobile-settings-menu { display:none !important; }
+ .mobile-settings.mobile-settings-root .mobile-settings-menu {
+ display:flex !important;
+ flex-direction:column;
+ gap:.5rem;
+ }
+ .mobile-settings.mobile-settings-root .mobile-settings-content { display:none !important; }
+ .mobile-settings .system-menu-brand { display:none !important; }
+ .mobile-settings .mobile-settings-menu button,
+ .mobile-settings details:not(.mobile-settings-current):not(.mobile-settings-ancestor) > summary {
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ gap:.75rem;
+ min-height:44px;
+ box-sizing:border-box;
+ text-align:left;
+ }
+ .mobile-settings .mobile-settings-chevron {
+ display:inline;
+ font-size:1.5rem;
+ line-height:1;
+ flex-shrink:0;
+ }
+ #setupPiModal.mobile-settings details > summary::before { display:none; }
+ .mobile-settings summary { list-style:none; }
+ .mobile-settings summary::-webkit-details-marker { display:none; }
+ .mobile-settings .mobile-settings-hidden { display:none !important; }
+ #setupPiModal.mobile-settings .system-settings-content,
+ #setupPiModal.mobile-settings .settings-pane,
+ .mobile-settings .sensor-pane-view,
+ .mobile-settings .sensor-pane-scroll,
+ .mobile-settings .switch-pane {
+ height:auto;
+ min-height:0;
+ max-height:none;
+ overflow:visible;
+ }
+ #setupPiModal.mobile-settings .system-settings-content { padding:.5rem; }
+ #setupPiModal.mobile-settings .integrations-list,
+ #setupPiModal.mobile-settings .advanced-sections { overflow:visible; }
+ .mobile-settings .mobile-settings-current > summary { display:none !important; }
+ #setupPiModal.mobile-settings .mobile-settings-ancestor {
+ border:0;
+ background:none;
+ }
+ #setupPiModal.mobile-settings .mobile-settings-ancestor > .integration-block-content { padding:0; }
+ .mobile-settings input:not([type='checkbox']):not([type='radio']),
+ .mobile-settings select, .mobile-settings textarea {
+ min-width:0;
+ max-width:100%;
+ box-sizing:border-box;
+ font-size:16px;
+ }
+ .mobile-settings .row.two, .mobile-settings .row.three,
+ .mobile-settings .display-metrics-row { grid-template-columns:minmax(0, 1fr); }
+ #setupPiModal.mobile-settings .thumbnail-options {
+ min-width:0;
+ grid-template-columns:repeat(2, minmax(0, 1fr));
+ }
+ #setupPiModal.mobile-settings .thumbnail-selector { overflow:visible; }
+ .mobile-settings .settings-modal-header { flex-shrink:0; }
+ .mobile-settings .settings-modal-header { cursor:default; touch-action:auto; }
+}
diff --git a/ui_static/js/draggable_modals.js b/ui_static/js/draggable_modals.js
index 6e9d05a..2f1b2ac 100644
--- a/ui_static/js/draggable_modals.js
+++ b/ui_static/js/draggable_modals.js
@@ -44,6 +44,7 @@
const modal = handle.closest(MODAL_SELECTOR);
if (!modal || !document.body.contains(modal)) return;
+ if (modal.closest(".mobile-settings")) return;
const rect = modal.getBoundingClientRect();
modal.style.width = `${rect.width}px`;
diff --git a/ui_static/js/mobile_settings.js b/ui_static/js/mobile_settings.js
new file mode 100644
index 0000000..81008db
--- /dev/null
+++ b/ui_static/js/mobile_settings.js
@@ -0,0 +1,160 @@
+/* Mobile settings navigation keeps the original forms and save handlers in place. */
+"use strict";
+
+(() => {
+if (window.initMobileSettings) return;
+const phoneQuery = "(max-width: 700px), (max-device-width: 700px)";
+const viewportOwners = new Set();
+let settingsViewport = null;
+window.setMobileWorkspaceViewport = function setMobileWorkspaceViewport(root, open, query = phoneQuery) {
+ if (!root) return;
+ if (open) viewportOwners.add(root);
+ else viewportOwners.delete(root);
+ for (const owner of viewportOwners) {
+ if (!owner.isConnected) viewportOwners.delete(owner);
+ }
+ if (viewportOwners.size && window.matchMedia(query).matches) {
+ if (!document.querySelector('meta[name="viewport"]')) {
+ settingsViewport = document.createElement("meta");
+ settingsViewport.name = "viewport";
+ settingsViewport.content = "width=device-width, initial-scale=1";
+ document.head.append(settingsViewport);
+ }
+ } else if (!viewportOwners.size && settingsViewport) {
+ // Reset the viewport directives first; removing the tag alone retains its scale.
+ settingsViewport.content = "";
+ settingsViewport.remove();
+ settingsViewport = null;
+ }
+};
+
+window.initMobileSettings = function initMobileSettings(root) {
+ if (!root || root.__mobileSettings) return root?.__mobileSettings;
+ const system = root.id === "setupPiModal";
+ const menu = root.querySelector(system ? ".system-settings-menu" : ".switch-dialog-menu");
+ const content = root.querySelector(system ? ".system-settings-content" : ".split > section.panel");
+ const body = root.querySelector(system ? ".system-settings-body" : ".modal-body");
+ if (!menu || !content || !body) return null;
+ window.setMobileWorkspaceViewport(root, true);
+ const media = window.matchMedia(phoneQuery);
+ const paneSelector = ".settings-pane, .sensor-pane-view, .switch-pane";
+ const nav = document.createElement("nav");
+ nav.className = "mobile-settings-nav";
+ nav.setAttribute("aria-label", "Settings navigation");
+ const back = document.createElement("button");
+ back.type = "button";
+ back.textContent = "< Back";
+ const title = document.createElement("span");
+ title.className = "mobile-settings-title";
+ nav.append(back, title);
+ body.before(nav);
+ menu.classList.add("mobile-settings-menu");
+ content.classList.add("mobile-settings-content");
+ [...menu.querySelectorAll("button"), ...content.querySelectorAll("details > summary")].forEach((item) => {
+ item.dataset.mobileLabel = item.textContent.trim();
+ const chevron = document.createElement("span");
+ chevron.className = "mobile-settings-chevron";
+ chevron.setAttribute("aria-hidden", "true");
+ item.append(chevron);
+ });
+ let pane = null;
+ let trigger = null;
+ let path = [];
+ let desktopOpen = new Map();
+
+ function clearPath() {
+ root.querySelectorAll(".mobile-settings-hidden, .mobile-settings-current, .mobile-settings-ancestor").forEach((element) => {
+ element.classList.remove("mobile-settings-hidden", "mobile-settings-current", "mobile-settings-ancestor");
+ });
+ }
+
+ function render(focusTarget) {
+ clearPath();
+ root.classList.toggle("mobile-settings-root", !pane);
+ back.hidden = !pane;
+ title.textContent = path.length
+ ? path[path.length - 1].querySelector(":scope > summary").dataset.mobileLabel
+ : (pane ? trigger?.dataset.mobileLabel || "Settings" : "Settings");
+ if (media.matches) {
+ content.querySelectorAll("details").forEach((detail) => { detail.open = path.includes(detail); });
+ if (path.length) {
+ const current = path[path.length - 1];
+ current.classList.add("mobile-settings-current");
+ // Hide sibling branches without moving inputs outside their owning form.
+ for (let branch = current; branch && branch !== pane; branch = branch.parentElement) {
+ for (const sibling of branch.parentElement.children) {
+ if (sibling !== branch && !sibling.matches(".pane-footer, .sai-live-status, input[type='hidden']")) {
+ sibling.classList.add("mobile-settings-hidden");
+ }
+ }
+ if (branch !== current && branch.matches("details")) branch.classList.add("mobile-settings-ancestor");
+ }
+ }
+ }
+ body.scrollTop = 0;
+ content.scrollTop = 0;
+ if (pane) pane.scrollTop = 0;
+ if (focusTarget) focusTarget.focus({ preventScroll: true });
+ }
+
+ function reset() {
+ pane = null;
+ trigger = null;
+ path = [];
+ render();
+ }
+
+ root.addEventListener("click", (event) => {
+ if (!media.matches) return;
+ const button = event.target.closest(".system-menu-btn, #sensorSettingsMenu [data-pane-target], #switchMenuSettings, #switchMenuStatistics");
+ if (button && menu.contains(button)) {
+ const target = button.dataset.target || button.dataset.paneTarget ||
+ (button.id === "switchMenuSettings" ? "switchSettingsPane" : "switchStatisticsPane");
+ pane = root.querySelector("#" + target);
+ trigger = button;
+ path = [];
+ render(back);
+ return;
+ }
+ const summary = event.target.closest("summary");
+ if (!summary || !content.contains(summary) || !summary.closest(paneSelector)) return;
+ event.preventDefault();
+ const detail = summary.parentElement;
+ if (path.includes(detail)) return;
+ pane = summary.closest(paneSelector);
+ path.push(detail);
+ render(back);
+ });
+
+ back.addEventListener("click", () => {
+ let focusTarget;
+ if (path.length) focusTarget = path.pop().querySelector(":scope > summary");
+ else {
+ focusTarget = trigger;
+ pane = null;
+ }
+ render(focusTarget);
+ });
+
+ function resize() {
+ root.classList.toggle("mobile-settings", media.matches);
+ if (media.matches) {
+ desktopOpen = new Map(Array.from(content.querySelectorAll("details"), detail => [detail, detail.open]));
+ reset();
+ } else {
+ clearPath();
+ desktopOpen.forEach((open, detail) => { detail.open = open; });
+ }
+ }
+ // A shared resize dispatcher avoids retaining removed device dialogs.
+ root.__mobileSettings = { reset, resize };
+ resize();
+ return root.__mobileSettings;
+};
+
+window.matchMedia(phoneQuery).addEventListener("change", () => {
+ document.querySelectorAll("#setupPiModal, #sensorSettingsModal, #switchSettingsModal").forEach((root) => {
+ root.__mobileSettings?.resize();
+ });
+});
+})();
diff --git a/ui_static/js/sensor_settings_modal.js b/ui_static/js/sensor_settings_modal.js
index f7dd7c1..ecd530a 100644
--- a/ui_static/js/sensor_settings_modal.js
+++ b/ui_static/js/sensor_settings_modal.js
@@ -3,6 +3,7 @@
window.initSensorSettingsModal = function initSensorSettingsModal(modalEl) {
if (!modalEl) return false;
+ window.initMobileSettings?.(modalEl);
const menu = modalEl.querySelector("#sensorSettingsMenu");
const panes = modalEl.querySelectorAll(".sensor-pane-view");
diff --git a/ui_static/weather_forecast/app.css b/ui_static/weather_forecast/app.css
index 9204b3b..e2b1116 100644
--- a/ui_static/weather_forecast/app.css
+++ b/ui_static/weather_forecast/app.css
@@ -557,13 +557,14 @@ body.caelus-theme-preview-mode .dashboard-return { opacity: 0; pointer-events: n
.future-heading button { padding: 0.3rem 0.5rem; border: 1px solid var(--line); background: rgba(255,255,255,.06); color: var(--ink); font-size: 0.52rem; cursor: pointer; }
.future-heading button:hover { border-color: var(--accent-2); }
.daily-strip { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0.4rem; margin-top: 0.65rem; }
-.forecast-day { min-width: 0; overflow: hidden; padding: 0.65rem 0.25rem; text-align: center; border: 1px solid var(--line); border-radius: 0.75rem; background: rgba(2,28,35,.18); }
+.forecast-day { min-width: 0; overflow: hidden; padding: 0.45rem 0.25rem; text-align: center; border: 1px solid var(--line); border-radius: 0.75rem; background: rgba(2,28,35,.18); }
.forecast-day time, .forecast-day > span, .forecast-day strong, .forecast-day small { display: block; }
.forecast-day time { color: var(--muted); font-size: 0.54rem; font-weight: 700; }
-.forecast-day > span { margin: 0.35rem 0; font-size: 1.45rem; }
+.forecast-day > span { margin: 0.18rem 0; font-size: 1.25rem; line-height: 1; }
.forecast-day strong { font-size: 0.76rem; }
.forecast-day strong em { color: #6dc8ff; font-style: normal; font-weight: 600; }
-.forecast-day small { margin-top: 0.25rem; color: var(--accent-2); font-size: 0.48rem; }
+.forecast-day small { margin-top: 0.18rem; color: var(--accent-2); font-size: 0.5rem; line-height: 1.25; }
+.forecast-day small.forecast-day-stat { color: var(--muted); }
.forecast-dialog { width: min(72rem, calc(100vw - 2rem)); max-height: calc(100vh - 2rem); margin: auto; padding: 0; overflow: auto; color: var(--ink); border: 1px solid var(--line); border-radius: 1rem; background: var(--glass-strong); box-shadow: 0 30px 100px rgba(0,15,19,.5); }
.forecast-dialog::backdrop { background: rgba(2,18,23,.72); backdrop-filter: blur(8px); }
.forecast-dialog > header { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.3rem; background: var(--glass); border-bottom: 1px solid var(--line); }
diff --git a/ui_templates/modals/switch_settings.html b/ui_templates/modals/switch_settings.html
index d8c1354..f1449a7 100644
--- a/ui_templates/modals/switch_settings.html
+++ b/ui_templates/modals/switch_settings.html
@@ -180,6 +180,7 @@ Statistics
diff --git a/ui_templates/weather_forecast/index.html b/ui_templates/weather_forecast/index.html
index dec8cf8..34c38cb 100644
--- a/ui_templates/weather_forecast/index.html
+++ b/ui_templates/weather_forecast/index.html
@@ -80,8 +80,22 @@ {{ location.name or 'Your weather, alive.' }}
>
Hours 1–{{ 8 if hour_count > 8 else hour_count }} of {{ hour_count }}
- {{ 'Cached forecast · provider temporarily unavailable' if forecast.stale else 'Updated from ' ~ forecast.provider_label }}
- {% if forecast.days %}Looking ahead 6-day details
{% for day in forecast.days %}
{{ day.label }} {{ weather_icon(day.icon_key, day.summary) }} {{ day.temp_range }} {{ reading(day.precip_probability, '%') }} {{ day.precip_label | lower }} {% endfor %}
{% endif %}
+ {% if forecast.stale %}Cached forecast · provider temporarily unavailable
{% endif %}
+ {% if forecast.days %}
+ Looking ahead
+
+ {% for day in forecast.days %}
+
+ {{ day.label }}
+ {{ weather_icon(day.icon_key, day.summary) }}
+ {{ day.temp_range }}
+ RH {{ day.rh_range }}
+ Wind {{ day.wind_range }}
+ {{ 'Snow' if day.precip_label == 'Snow chance' else 'Rain' }} {{ reading(day.precip_probability, '%') }}
+
+ {% endfor %}
+
+ {% endif %}
{% else %}◌ {{ forecast.reason or 'Set an Astral location and forecast provider to load the forecast.' }}
{% endif %}