From b14343b83185f5ecdf385f1a052793d74100a87c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Jul 2026 08:42:58 +0200 Subject: [PATCH 1/4] Make the demo a Home Assistant Green running Home Assistant OS (#53326) * Make the apps panel work in the demo The apps panel is gated on the hassio component, so the demo redirected to the "what is an app?" explainer instead of showing an app store. Enable hassio and mock the supervisor endpoints behind it, with Music Assistant and ESPHome Device Builder installed and running. Enabling hassio also unlocks the supervisor parts of the system, storage, hardware and network pages, so those endpoints are mocked too, otherwise they render error banners. Add a callApiRaw mock to provideHass so the app log tab can stream logs; it wraps the existing REST mocks in a Response. HassioAddonDetails.rating was typed "1-8" but is a number 1-8, as its only consumers (RATING_ICON lookup, Number() comparisons) and the equivalent field on StoreAddonDetails already assume. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz * Show Home Assistant Green on OS 18.2 in the demo, drop app changelogs Present the demo as a Home Assistant Green running Home Assistant OS 18.2, and align the supervisor version and channel with it instead of the stale 2021 dev build the unused stub carried. BOARD_NAMES had no entry for green, so the hardware page fell back to "Generic hardware" with a raw "green" board id. Mock /info as well; it is fetched by the about page once hassio is loaded. Drop the demo changelogs. The app info page rendered the changelog link for every installed app, ignoring the changelog flag the supervisor sends to say whether the app ships one, so gate the link on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz * Mock the hardware integration in the demo instead of BOARD_NAMES The hardware page only gets a board picture and documentation link from the hardware integration's info; the BOARD_NAMES map is a bare-name fallback for when that integration is not loaded, which is why the page rendered a name with no image. Mock hardware/info with what homeassistant_green reports, so the board name, brands image and documentation link resolve the way they do on a real Green, and revert the BOARD_NAMES entry added for the fallback. Also mock hardware/subscribe_system_status so the processor and memory charts have data, rounded the way the integration rounds psutil's values. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz * Inline the changelog link and drop the demo component comment Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz * Stream demo system status every second Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz * Keep enabling hassio from the supervisor stub The gallery calls mockHassioSupervisor from five pages and relies on it to load the hassio component; dropping the push left the app selector and the automation editors' hassio-gated pickers with nothing to fetch. Restore it, guarded, since the demo now lists hassio in its own components. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz * Note in the demo guide that the gallery imports its stubs Twenty-five gallery pages import from demo/src/stubs, so a change that looks fine in the demo can break the gallery. Also record why the demo is not a reliable check: the gallery sees in-place hass.config mutations, the demo does not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz * Guard the store changelog link too Per review: the not-installed branch should honour the same changelog flag. The supervisor reports it for store apps as well, alongside documentation, so declare it on StoreAddonDetails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4Xz1qN5chrxgPnC5V4PGz --------- Co-authored-by: Claude Co-authored-by: Petar Petrov --- demo/AGENTS.md | 10 + demo/src/ha-demo.ts | 10 +- demo/src/stubs/hardware.ts | 48 +++ demo/src/stubs/hassio_supervisor.ts | 398 ++++++++++++++++-- src/data/hassio/addon.ts | 2 +- src/data/supervisor/store.ts | 1 + src/fake_data/provide_hass.ts | 11 + .../apps/app-view/info/supervisor-app-info.ts | 37 +- 8 files changed, 464 insertions(+), 53 deletions(-) create mode 100644 demo/src/stubs/hardware.ts diff --git a/demo/AGENTS.md b/demo/AGENTS.md index 54ffdb7eeb..015f56694b 100644 --- a/demo/AGENTS.md +++ b/demo/AGENTS.md @@ -44,3 +44,13 @@ http://localhost:8090/?demo=#/energy/water - `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling. - `src/stubs/`: Mocked WebSocket/REST APIs. - `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers. + +## The gallery imports these stubs too + +`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, grep for its callers across `gallery/` as well as `demo/`, and check the affected gallery pages, not just the demo. + +```bash +grep -rn "stubs/" demo/src gallery/src +``` + +The two consume a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object, so a stub that mutates `hass.config` in place is visible to the page. `ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can therefore look fine in the demo while quietly breaking a gallery page. diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index 9d40036e29..b379ebdddf 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -16,6 +16,8 @@ import { mockEntityRegistry } from "./stubs/entity_registry"; import { mockEvents } from "./stubs/events"; import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry"; import { mockFrontend } from "./stubs/frontend"; +import { mockHardware } from "./stubs/hardware"; +import { mockHassioSupervisor } from "./stubs/hassio_supervisor"; import { mockIntegration } from "./stubs/integration"; import { mockLabelRegistry } from "./stubs/label_registry"; import { mockIcons } from "./stubs/icons"; @@ -73,10 +75,6 @@ export class HaDemo extends HomeAssistantAppEl { // `contextMixin`, so let provideHass skip them to avoid duplicate providers. const hass = provideHass(this, initial, true, false); - // The cloud account page only fetches backup config and the webhook count - // when those integrations are loaded. Enable them here (demo only) so the - // mocked backup/config/info and webhook/list are queried. usage_prediction - // is needed for common-controls sections in strategy dashboards. hass.updateHass({ config: { ...hass.config, @@ -86,6 +84,8 @@ export class HaDemo extends HomeAssistantAppEl { "webhook", "usage_prediction", "assist_pipeline", + "hassio", + "hardware", ], }, }); @@ -113,6 +113,8 @@ export class HaDemo extends HomeAssistantAppEl { mockEvents(hass); mockMediaPlayer(hass); mockFrontend(hass); + mockHardware(hass); + mockHassioSupervisor(hass); mockIcons(hass); mockEnergy(hass); mockPersistentNotification(hass); diff --git a/demo/src/stubs/hardware.ts b/demo/src/stubs/hardware.ts new file mode 100644 index 0000000000..a758285fed --- /dev/null +++ b/demo/src/stubs/hardware.ts @@ -0,0 +1,48 @@ +import type { + HardwareInfo, + SystemStatusStreamMessage, +} from "../../../src/data/hardware"; +import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; + +// Mirrors what homeassistant_green reports, so the hardware page resolves the +// board name and the brands image the same way it does on a real Green. +const HARDWARE_INFO: HardwareInfo = { + hardware: [ + { + board: { + hassio_board_id: "green", + manufacturer: "homeassistant", + model: "green", + }, + dongle: null, + config_entries: [], + name: "Home Assistant Green", + url: "https://support.nabucasa.com/hc/en-us/categories/24638797677853-Home-Assistant-Green", + }, + ], +}; + +export const mockHardware = (hass: MockHomeAssistant) => { + hass.mockWS("hardware/info", () => HARDWARE_INFO); + + hass.mockWS( + "hardware/subscribe_system_status", + (_msg, _currentHass, onChange) => { + // Rounded like the hardware integration rounds psutil's values. + const send = () => { + const usedMb = 1560 + Math.round(Math.random() * 80); + const message: SystemStatusStreamMessage = { + cpu_percent: Math.round((8 + Math.random() * 6) * 10) / 10, + memory_free_mb: 4096 - usedMb, + memory_used_mb: usedMb, + memory_used_percent: Math.round((usedMb / 4096) * 1000) / 10, + timestamp: new Date().toISOString(), + }; + onChange?.(message); + }; + send(); + const interval = window.setInterval(send, 1000); + return () => clearInterval(interval); + } + ); +}; diff --git a/demo/src/stubs/hassio_supervisor.ts b/demo/src/stubs/hassio_supervisor.ts index c3ee8ce760..e7b7f7e6b3 100644 --- a/demo/src/stubs/hassio_supervisor.ts +++ b/demo/src/stubs/hassio_supervisor.ts @@ -1,59 +1,385 @@ -import type { HassioSupervisorInfo } from "../../../src/data/hassio/supervisor"; +import type { + HassioAddonDetails, + HassioAddonInfo, + HassioAddonsInfo, +} from "../../../src/data/hassio/addon"; +import type { HassioStats } from "../../../src/data/hassio/common"; +import type { + HassioHassOSInfo, + HassioHostInfo, + HostDisksUsage, +} from "../../../src/data/hassio/host"; +import type { NetworkInfo } from "../../../src/data/hassio/network"; +import type { + HassioInfo, + HassioSupervisorInfo, +} from "../../../src/data/hassio/supervisor"; +import type { SupervisorMounts } from "../../../src/data/supervisor/mounts"; +import type { SupervisorUpdateConfig } from "../../../src/data/supervisor/update"; import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; +// `icon`/`logo` are false on purpose: the panel would otherwise request +// /api/hassio/addons//icon, which the demo has no backend for. +const DEMO_ADDONS: HassioAddonInfo[] = [ + { + name: "Music Assistant", + slug: "d5369777_music_assistant", + description: + "Music library manager for all your media sources and streaming services, with support for a wide range of players", + advanced: false, + available: true, + build: false, + detached: false, + homeassistant: "2025.7.0", + icon: false, + installed: true, + logo: false, + repository: "d5369777", + stage: "stable", + state: "started", + update_available: false, + url: "https://github.com/music-assistant/home-assistant-addon", + version: "2.6.3", + version_latest: "2.6.3", + }, + { + name: "ESPHome Device Builder", + slug: "5c53de3b_esphome", + description: + "Manage and program your ESP8266/ESP32 based microcontrollers directly via WiFi and with a simple, yet powerful configuration file syntax", + advanced: false, + available: true, + build: false, + detached: false, + homeassistant: "2025.7.0", + icon: false, + installed: true, + logo: false, + repository: "5c53de3b", + stage: "stable", + state: "started", + update_available: false, + url: "https://esphome.io/", + version: "2025.7.3", + version_latest: "2025.7.3", + }, +]; + +const LONG_DESCRIPTIONS: Record = { + d5369777_music_assistant: `## Music Assistant + +Music Assistant brings all your music sources together in one library and streams +them to the players you already own. + +- Combines local files with streaming services into a single searchable library +- Plays to Sonos, Chromecast, AirPlay, Squeezebox, DLNA and Home Assistant media players +- Group players together for synced multi-room audio +- Exposes players and playlists to Home Assistant automations and voice assistants`, + "5c53de3b_esphome": `## ESPHome Device Builder + +ESPHome turns an ESP8266 or ESP32 into a Home Assistant device using a short YAML +configuration instead of hand-written firmware. + +- Compile and flash firmware straight from the browser, over WiFi after the first flash +- Hundreds of supported sensors, displays, lights and switches +- Devices are discovered by Home Assistant automatically, with no cloud in between +- Configuration lives next to your Home Assistant config, so it is covered by backups`, +}; + +// Supervisor schema format (converted to selectors by the config tab). Music +// Assistant is configured in its own UI, so it has no add-on options. +const CONFIG_SCHEMAS: Record = { + "5c53de3b_esphome": [ + { name: "ssl", type: "boolean", required: true }, + { name: "certfile", type: "string", required: true }, + { name: "keyfile", type: "string", required: true }, + { name: "leave_front_door_open", type: "boolean", required: false }, + { name: "status_use_ping", type: "boolean", required: false }, + ], +}; + +const CONFIG_OPTIONS: Record> = { + "5c53de3b_esphome": { + ssl: false, + certfile: "fullchain.pem", + keyfile: "privkey.pem", + }, +}; + +const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({ + ...addon, + apparmor: "default", + arch: ["aarch64", "amd64"], + audio_input: null, + audio_output: null, + audio: false, + auth_api: false, + auto_uart: false, + auto_update: false, + boot: "auto", + changelog: false, + devices: [], + devicetree: false, + discovery: [], + docker_api: false, + documentation: false, + full_access: false, + gpio: false, + hassio_api: false, + hassio_role: "default", + hostname: addon.slug.replace(/_/g, "-"), + homeassistant_api: false, + host_dbus: false, + host_ipc: false, + host_network: false, + host_pid: false, + ingress_entry: null, + ingress_panel: false, + ingress_url: null, + ingress: false, + ip_address: "172.30.33.2", + kernel_modules: false, + long_description: LONG_DESCRIPTIONS[addon.slug], + machine: [], + network_description: null, + network: null, + options: CONFIG_OPTIONS[addon.slug] ?? {}, + privileged: [], + protected: true, + rating: 6, + schema: CONFIG_SCHEMAS[addon.slug] ?? null, + services_role: [], + signed: false, + startup: "application", + stdin: false, + system_managed: false, + system_managed_config_entry: null, + translations: {}, + watchdog: true, + webui: null, +}); + +const LOGS: Record = { + d5369777_music_assistant: `[server] Starting Music Assistant Server 2.6.3 +[server] Loaded provider: filesystem_local +[server] Loaded provider: spotify +[server] Loaded provider: sonos +[players] Discovered player: Living Room (Sonos) +[players] Discovered player: Kitchen (Chromecast) +[server] Music Assistant is ready +`, + "5c53de3b_esphome": `[esphome] Starting ESPHome Device Builder 2025.7.3 +[esphome] Dashboard running on port 6052 +[esphome] Found 3 configurations +[esphome] bedroom-sensor is online (2025.7.3) +[esphome] garage-door is online (2025.7.3) +[esphome] office-display is online (2025.7.3) +`, +}; + +const ADDON_STATS: HassioStats = { + blk_read: 12300000, + blk_write: 4500000, + cpu_percent: 1.4, + memory_limit: 3900000000, + memory_percent: 4.2, + memory_usage: 163000000, + network_rx: 8900000, + network_tx: 2300000, +}; + export const mockHassioSupervisor = (hass: MockHomeAssistant) => { - hass.config.components.push("hassio"); + // Gallery pages rely on this to enable the hassio-gated pickers. The demo + // lists hassio in its own components, hence the guard against duplicates. + if (!hass.config.components.includes("hassio")) { + hass.config.components.push("hassio"); + } + hass.mockWS("supervisor/api", (msg) => { if (msg.endpoint === "/supervisor/info") { const data: HassioSupervisorInfo = { - version: "2021.10.dev0805", - version_latest: "2021.10.dev0806", - update_available: true, - channel: "dev", + version: "2026.07.1", + version_latest: "2026.07.1", + update_available: false, + channel: "stable", arch: "aarch64", supported: true, healthy: true, ip_address: "172.30.32.2", wait_boot: 5, - timezone: "America/Los_Angeles", + timezone: "Europe/Amsterdam", logging: "info", debug: false, debug_block: false, diagnostics: true, - addons: [ - { - name: "Visual Studio Code", - slug: "a0d7b954_vscode", - description: - "Fully featured VSCode experience, to edit your HA config in the browser, including auto-completion!", - state: "started", - version: "3.6.2", - version_latest: "3.6.2", - update_available: false, - repository: "a0d7b954", - icon: false, - logo: true, - }, - { - name: "Z-Wave JS", - slug: "core_zwave_js", - description: - "Control a ZWave network with Home Assistant Z-Wave JS", - state: "started", - version: "0.1.45", - version_latest: "0.1.45", - update_available: false, - repository: "core", - icon: true, - logo: true, - }, - ] as any, + addons: DEMO_ADDONS as any, addons_repositories: [ - "https://github.com/hassio-addons/repository", + "https://github.com/music-assistant/home-assistant-addon", + "https://github.com/esphome/home-assistant-addon", ] as any, }; return data; } + + if (msg.endpoint === "/addons") { + const data: HassioAddonsInfo = { + addons: DEMO_ADDONS, + repositories: [ + { + slug: "d5369777", + name: "Music Assistant", + source: "https://github.com/music-assistant/home-assistant-addon", + url: "https://github.com/music-assistant/home-assistant-addon", + maintainer: "Music Assistant", + }, + { + slug: "5c53de3b", + name: "ESPHome", + source: "https://github.com/esphome/home-assistant-addon", + url: "https://esphome.io/", + maintainer: "ESPHome", + }, + ], + }; + return data; + } + + const addonMatch = msg.endpoint.match(/^\/addons\/([^/]+)\/(info|stats)$/); + if (addonMatch) { + const addon = DEMO_ADDONS.find((item) => item.slug === addonMatch[1]); + if (!addon) { + return Promise.reject(`Addon ${addonMatch[1]} not found`); + } + return addonMatch[2] === "stats" ? ADDON_STATS : addonDetails(addon); + } + + if (msg.endpoint === "/info") { + const data: HassioInfo = { + arch: "aarch64", + channel: "stable", + docker: "27.5.1", + features: ["reboot", "shutdown", "network", "hostname", "os_agent"], + hassos: null, + homeassistant: "2026.7.2", + hostname: "homeassistant", + logging: "info", + machine: "green", + state: "running", + operating_system: "Home Assistant OS 18.2", + supervisor: "2026.07.1", + supported: true, + supported_arch: ["aarch64", "armv7", "armhf"], + timezone: "Europe/Amsterdam", + }; + return data; + } + + if (msg.endpoint === "/host/info") { + const data: HassioHostInfo = { + agent_version: "1.8.0", + chassis: "embedded", + cpe: "cpe:2.3:o:home-assistant:haos:18.2:*:production:*:*:*:aarch64:*", + deployment: "production", + disk_life_time: 6, + disk_free: 22.3, + disk_total: 31.2, + disk_used: 8.9, + features: ["reboot", "shutdown", "network", "hostname", "os_agent"], + hostname: "homeassistant", + kernel: "6.12.48-haos", + operating_system: "Home Assistant OS 18.2", + boot_timestamp: 1751932800000000, + startup_time: 12.4, + }; + return data; + } + + if (msg.endpoint === "/os/info") { + const data: HassioHassOSInfo = { + board: "green", + boot: "A", + update_available: false, + version: "18.2", + version_latest: "18.2", + data_disk: "Home Assistant Green (mmcblk0)", + }; + return data; + } + + if (msg.endpoint === "/host/disks/default/usage") { + const data: HostDisksUsage = { + id: "root", + label: "Total", + total_bytes: 31200000000, + used_bytes: 8900000000, + children: [ + { id: "media", label: "Media", used_bytes: 4100000000 }, + { id: "addons", label: "Apps", used_bytes: 2600000000 }, + { id: "backup", label: "Backups", used_bytes: 1400000000 }, + { id: "share", label: "Share", used_bytes: 800000000 }, + ], + }; + return data; + } + + if (msg.endpoint === "/mounts") { + const data: SupervisorMounts = { + default_backup_mount: null, + mounts: [], + }; + return data; + } + + if (msg.endpoint === "/network/info") { + const data: NetworkInfo = { + interfaces: [ + { + primary: true, + privacy: false, + interface: "eth0", + enabled: true, + type: "ethernet", + ipv4: { + address: ["192.168.1.10/24"], + gateway: "192.168.1.1", + method: "auto", + nameservers: ["192.168.1.1"], + }, + wifi: null, + }, + ], + docker: { + address: "172.30.32.0/23", + dns: "172.30.32.3", + gateway: "172.30.32.1", + interface: "hassio", + }, + }; + return data; + } + + if (msg.endpoint === "/store/reload") { + return null; + } + return Promise.reject(`${msg.method} ${msg.endpoint} is not implemented`); }); + + hass.mockWS("hassio/update/config/info", (): SupervisorUpdateConfig => ({ + add_on_backup_before_update: true, + add_on_backup_retain_copies: 1, + core_backup_before_update: true, + })); + + hass.mockAPI(/^hassio\/host\/logs\/boots$/, () => ({ + data: { boots: { "0": "2026-07-26T09:00:00.000000+00:00" } }, + })); + + hass.mockAPI(/^hassio\/addons\/[^/]+\/logs/, (_hass, _method, path) => { + const slug = path.split("/")[2]; + // X-First-Cursor tells error-log-card there is nothing older to page to. + return new Response(LOGS[slug], { + headers: { "X-First-Cursor": "demo" }, + }); + }); }; diff --git a/src/data/hassio/addon.ts b/src/data/hassio/addon.ts index a67d1db4f8..7bd6fad32d 100644 --- a/src/data/hassio/addon.ts +++ b/src/data/hassio/addon.ts @@ -95,7 +95,7 @@ export interface HassioAddonDetails extends HassioAddonInfo { options: Record; privileged: any; protected: boolean; - rating: "1-8"; + rating: number; schema: HaFormSchema[] | null; services_role: string[]; signed: boolean; diff --git a/src/data/supervisor/store.ts b/src/data/supervisor/store.ts index 4aff294296..7b98d8860e 100644 --- a/src/data/supervisor/store.ts +++ b/src/data/supervisor/store.ts @@ -26,6 +26,7 @@ export interface StoreAddonDetails extends StoreAddon { apparmor: boolean; arch: SupervisorArch[]; auth_api: boolean; + changelog: boolean; detached: boolean; docker_api: boolean; documentation: boolean; diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index 4a0bf77184..2f4ce6317c 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -481,6 +481,17 @@ export const provideHass = ( ? response[1](hass(), method, path, parameters) : Promise.reject(`API Mock for ${path} is not implemented`); }, + // Mocks return a plain body; wrap it so callers can stream it like a fetch + // Response. Callbacks may return a Response themselves to set headers. + async callApiRaw(method, path, parameters, headers) { + const result = await hassObj.callApi( + method, + path, + parameters, + headers + ); + return result instanceof Response ? result : new Response(result); + }, hassUrl: (path?) => path, fetchWithAuth: () => Promise.reject("Not implemented"), sendWS: (msg) => hassObj.connection.sendMessage(msg), diff --git a/src/panels/config/apps/app-view/info/supervisor-app-info.ts b/src/panels/config/apps/app-view/info/supervisor-app-info.ts index fd909ffa5a..db81140bf1 100644 --- a/src/panels/config/apps/app-view/info/supervisor-app-info.ts +++ b/src/panels/config/apps/app-view/info/supervisor-app-info.ts @@ -225,20 +225,33 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) { "ui.panel.config.apps.dashboard.current_version", { version: this._currentAddon.version } )} -
- (${this.i18n.localize( - "ui.panel.config.apps.dashboard.changelog" - )}) -
+ ${ + this._currentAddon.changelog + ? html`
+ (${this.i18n.localize( + "ui.panel.config.apps.dashboard.changelog" + )}) +
` + : nothing + } ` : html`${this._currentAddon.version_latest} - ${this.i18n.localize( - "ui.panel.config.apps.dashboard.changelog" - )}` + ${ + this._currentAddon.changelog + ? html`${this.i18n.localize( + "ui.panel.config.apps.dashboard.changelog" + )}` + : nothing + }` } From fe8f32423f565ba9baf65df0b4209e15f739b3f3 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 28 Jul 2026 10:17:22 +0300 Subject: [PATCH 2/4] Fix flaky demo e2e sidebar helper on narrow viewports (#53348) Fix race in demo e2e sidebar helper on narrow viewports openDemoSidebar decided whether the sidebar was behind a modal drawer by sampling ha-menu-button visibility. That element renders nothing until its Lit contexts resolve, so right after load it reads as hidden on the narrow layout too. The helper then took the wide-layout branch, never opened the drawer, and the following panel assertion waited out its timeout on an attached-but-hidden #sidebar-panel-map. Read the layout from ha-drawer's type instead, which home-assistant-main sets while committing its first render, and wait for the drawer to report open after clicking the menu button. --- test/e2e/demo/helpers.ts | 43 ++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/test/e2e/demo/helpers.ts b/test/e2e/demo/helpers.ts index 80cf9be1b1..ceedd7e606 100644 --- a/test/e2e/demo/helpers.ts +++ b/test/e2e/demo/helpers.ts @@ -1,4 +1,5 @@ import { expect, type Page } from "@playwright/test"; +import type { HaDrawer } from "../../../src/components/ha-drawer"; import type { ThemeSettings } from "../../../src/types"; import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers"; @@ -66,26 +67,34 @@ export async function expectStoredDemoTheme( } export async function openDemoSidebar(page: Page) { - const menuButton = page.locator("ha-menu-button"); - if (await menuButton.isVisible()) { - const modalDrawer = page.locator("ha-drawer").locator("wa-drawer"); - await Promise.all([ - modalDrawer.evaluate( - (element) => - new Promise((resolve) => { - element.addEventListener("wa-after-show", () => resolve(), { - once: true, - }); - }) - ), - menuButton.click(), - ]); + const drawer = page.locator("ha-drawer"); + await expect(drawer).toBeAttached({ timeout: NAVIGATION_TIMEOUT }); + + // Pick the layout from ha-drawer, which home-assistant-main sets to "modal" + // while committing its very first render (the narrow media query is resolved + // synchronously in its constructor). ha-menu-button must not be used for + // this: it renders nothing until its Lit contexts resolve, so sampling its + // visibility straight after load reports "hidden" on the narrow layout too, + // and the drawer would then silently never be opened. + const isModal = await drawer.evaluate( + (element) => (element as HaDrawer).type === "modal" + ); + + if (!isModal) { + // Wide layout: the sidebar is permanently on screen. + await expect(page.locator("ha-sidebar")).toBeVisible({ + timeout: NAVIGATION_TIMEOUT, + }); return; } - await expect(page.locator("ha-sidebar")).toBeAttached({ - timeout: NAVIGATION_TIMEOUT, - }); + const menuButton = page.locator("ha-menu-button"); + await expect(menuButton).toBeVisible({ timeout: SHELL_TIMEOUT }); + await menuButton.click(); + // ha-drawer reflects `open` once the app has actually opened the drawer. + // The slide-in animation is covered by Playwright's stability check on the + // next interaction. + await expect(drawer).toHaveAttribute("open", "", { timeout: SHELL_TIMEOUT }); } export async function activateDemoSidebarPanel(page: Page, panel: string) { From 06580f3dbc6054624b750aa021b72723c302995c Mon Sep 17 00:00:00 2001 From: Marcin Bauer Date: Tue, 28 Jul 2026 09:29:52 +0200 Subject: [PATCH 3/4] Increase loading logomark pulse scale (#53349) Bump the dot pulse in the launch screen logomark from scale(1.2) to scale(1.3) so the animation reads more clearly. Co-authored-by: Claude Opus 5 (1M context) --- public/static/images/home-assistant-logo-loading.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/static/images/home-assistant-logo-loading.svg b/public/static/images/home-assistant-logo-loading.svg index 4cae9447b9..79b1afa79c 100644 --- a/public/static/images/home-assistant-logo-loading.svg +++ b/public/static/images/home-assistant-logo-loading.svg @@ -5,19 +5,19 @@ .dot-top { animation: pulse-top 1300ms 350ms linear infinite; } @keyframes pulse-left { 0% { transform: translate(50px, 69.634804px) scale(1); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } - 15.384615% { transform: translate(50px, 69.634804px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } + 15.384615% { transform: translate(50px, 69.634804px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } 38.461538%, 100% { transform: translate(50px, 69.634804px) scale(1); } } @keyframes pulse-right { 0%, 15.384615% { transform: translate(90px, 58.634798px) scale(1); } 15.384615% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); } - 30.769231% { transform: translate(90px, 58.634798px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } + 30.769231% { transform: translate(90px, 58.634798px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } 53.846154%, 100% { transform: translate(90px, 58.634798px) scale(1); } } @keyframes pulse-top { 0%, 30.769231% { transform: translate(70px, 37.6348px) scale(1); } 30.769231% { animation-timing-function: cubic-bezier(.39, .575, .565, 1); } - 46.153846% { transform: translate(70px, 37.6348px) scale(1.2); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } + 46.153846% { transform: translate(70px, 37.6348px) scale(1.3); animation-timing-function: cubic-bezier(.39, .575, .565, 1); } 69.230769%, 100% { transform: translate(70px, 37.6348px) scale(1); } } From e34c1b985712c8ddbc575b014735944a1f032ed8 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 28 Jul 2026 10:50:24 +0300 Subject: [PATCH 4/4] Fix launch screen styles breaking the index template (#53347) --- src/html/index.html.template | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/html/index.html.template b/src/html/index.html.template index 2d5d73c93c..0be360215a 100644 --- a/src/html/index.html.template +++ b/src/html/index.html.template @@ -132,11 +132,12 @@ } } @media (max-height: 560px) { - #ha-launch-screen .ha-launch-screen-spacer-top { + /* body selector to avoid minification causing bad jinja2 */ + body #ha-launch-screen .ha-launch-screen-spacer-top { margin-top: 24px; padding-top: 24px; } - #ha-launch-screen .ha-launch-screen-spacer-bottom { + body #ha-launch-screen .ha-launch-screen-spacer-bottom { padding-top: 16px; } .ohf-logo {