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. - -![Detailed six-day weather outlook using the selected Desert Bloom theme](<../assets/screenshots/weather-forecast-caelus-six-day.png>) +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 + +

+ Mobile dashboard with navigation icons, overview tiles, and sensor groups +

+ +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. + + + + + + + + +
Mobile settings menu with General Settings, Automations, device management, Integrations, and Advanced
1. Settings menu
General Settings submenu with Astral, Display, Network Settings, Nodus Wifi Update, Notifications, and Weather Forecast
2. General Settings
Display settings with units, metric set, display style, theme submenus, and Save
3. Display
Sensorius Dashboard Theme choices with Back and Save controls
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 Graphum chart with Time range controls below it
Portrait chart
Landscape Graphum chart spanning the phone screen
Landscape chart
Graphum scrolled down to expandable sensor metric and switch selectors
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 "" yield from _settings_gear_svg_lines(indent=" ") 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(" 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
{% for day in forecast.days %}
{{ 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 %} +
+ + {{ 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 %}