Compare commits

...

6 Commits

Author SHA1 Message Date
Aidan Timson 6c2c69ab7f Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-22 12:05:34 +01:00
Aidan Timson 7543370954 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-22 12:05:34 +01:00
Aidan Timson 39f5ab2746 Add demo theme persistence tests 2026-07-22 12:05:34 +01:00
Maarten Lakerveld 58ded99773 Onboarding survey (#53236)
Use new OHF shortlink service
2026-07-22 12:26:54 +02:00
Timothy 0d2b99b3a5 Disable LaunchScreen for mobile (#53171)
* Disable LaunchScreen for mobile

* Apply suggestions
2026-07-22 12:36:41 +03:00
WazoAkaRapace 4be7a32148 Fix energy graph off-by-one colors with export-only grid source (#53145)
Fix energy usage graph off-by-one colors with export-only grid source

When a grid source configured without an import sensor (export-only) was
listed before other grid sources, the energy usage graph card assigned
color indices based on the filtered `statIds.from_grid` array — skipping
that source — instead of the positional index in the user's energy
sources config. This shifted every subsequent grid source's color by one
relative to the energy sources table card, which uses positional indices.

Populate `colorIndices.from_grid` and `colorIndices.to_grid` during the
source loop using a positional `gridIdx` counter, so import-only and
export-only grid sources no longer shift the colors of sources that
follow them.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-22 11:43:04 +03:00
9 changed files with 162 additions and 18 deletions
+2 -1
View File
@@ -124,7 +124,7 @@ interface EMOutgoingMessageConnectionStatus extends EMMessage {
}
interface EMOutgoingMessageFrontendLoaded extends EMMessage {
type: "frontend/loaded"; // Fired once the launch screen is removed (connected and essential data loaded)
type: "frontend/loaded"; // Fired once the launch screen is removed; with hasSplashscreen this is after the first panel has rendered
}
interface EMOutgoingMessageAppConfiguration extends EMMessage {
@@ -371,6 +371,7 @@ export interface ExternalConfig {
appVersion?: string;
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
}
export interface ExternalEntityAddToAction {
+6 -1
View File
@@ -133,7 +133,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.render = this.renderHass;
this.update = super.update;
removeLaunchScreen();
// Apps with a native splash screen keep covering the frontend until
// frontend/loaded, so the launch screen stays up (invisibly) until the
// first panel has rendered and partial-panel-resolver removes it.
if (!this.hass.auth.external?.config.hasSplashscreen) {
removeLaunchScreen();
}
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
super.update(changedProps);
+2 -1
View File
@@ -220,7 +220,8 @@ class PartialPanelResolver extends HassRouterPage {
) {
await this.rebuild();
await this.pageRendered;
removeLaunchScreen();
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen);
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
}
}
@@ -297,6 +297,15 @@ export class HuiEnergyUsageGraphCard
to_battery: {},
};
// Grid sources can be import-only or export-only; assign color indices by
// position in the user's grid sources config so this card matches the
// energy sources table, which uses positional indices.
const colorIndices: Record<string, Record<string, number>> = {};
Object.keys(colorPropertyMap).forEach((key) => {
colorIndices[key] = {};
});
let gridIdx = 0;
for (const source of energyData.prefs.energy_sources) {
if (source.type === "solar") {
if (statIds.solar) {
@@ -335,6 +344,7 @@ export class HuiEnergyUsageGraphCard
} else {
statIds.from_grid = [gridSource.stat_energy_from];
}
colorIndices.from_grid[gridSource.stat_energy_from] = gridIdx;
if (gridSource.name) {
statLabels.from_grid[gridSource.stat_energy_from] =
gridSource.stat_energy_to
@@ -351,6 +361,7 @@ export class HuiEnergyUsageGraphCard
} else {
statIds.to_grid = [gridSource.stat_energy_to];
}
colorIndices.to_grid[gridSource.stat_energy_to] = gridIdx;
if (gridSource.name) {
statLabels.to_grid[gridSource.stat_energy_to] =
gridSource.stat_energy_from
@@ -361,17 +372,18 @@ export class HuiEnergyUsageGraphCard
: gridSource.name;
}
}
gridIdx++;
}
const computedStyles = getComputedStyle(this);
const colorIndices: Record<string, Record<string, number>> = {};
Object.keys(colorPropertyMap).forEach((key) => {
colorIndices[key] = {};
if (
key === "used_grid" ||
key === "used_solar" ||
key === "used_battery"
key === "used_battery" ||
key === "from_grid" ||
key === "to_grid"
) {
return;
}
+21 -3
View File
@@ -3,10 +3,27 @@ import { render } from "lit";
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
import { withViewTransition } from "../common/util/view-transition";
export const removeLaunchScreen = () => {
let removalInitiated = false;
/**
* Removes the launch screen with a fade-out view transition.
*
* @param instant - Removes the launch screen without animation. Used when the
* external app covers the frontend with its own splash screen until the
* `frontend/loaded` event, where the animation would play invisibly underneath.
* @returns Whether this call initiated the removal (false when the removal
* was already initiated, e.g. while the fade-out is still running).
*/
export const removeLaunchScreen = (instant = false): boolean => {
const launchScreenElement = document.getElementById("ha-launch-screen");
if (!launchScreenElement?.parentElement) {
return;
if (removalInitiated || !launchScreenElement?.parentElement) {
return false;
}
removalInitiated = true;
if (instant) {
launchScreenElement.parentElement.removeChild(launchScreenElement);
return true;
}
withViewTransition((viewTransitionAvailable: boolean) => {
@@ -25,6 +42,7 @@ export const removeLaunchScreen = () => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
}, parseAnimationDuration(durationFromCss));
});
return true;
};
export const renderLaunchScreenInfoBox = (content: TemplateResult) => {
+1 -1
View File
@@ -14,7 +14,7 @@ const SURVEY_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 1 month
// version-specific (the version is passed as a query parameter instead), so
// beta/dev installs should not be routed to rc./next. like documentationUrl
// would.
const SURVEY_URL = "https://www.home-assistant.io/surveys/onboarding";
const SURVEY_URL = "https://ohf.to/ha/surveys/onboarding";
export const shouldShowOnboardingSurvey = (
user: CurrentUser | undefined,
+69 -6
View File
@@ -10,9 +10,12 @@ import {
import {
activateDemoSidebarPanel,
demoCardSelector,
expectDemoDarkMode,
expectStoredDemoTheme,
loadDemo,
moreInfoCardSelector,
openDemoSidebar,
waitForDemoReady,
reloadDemo,
} from "./demo/helpers";
test.describe("Home Assistant Demo", () => {
@@ -20,17 +23,16 @@ test.describe("Home Assistant Demo", () => {
test.beforeEach(async ({ page }) => {
pageErrors = trackPageErrors(page);
await page.goto("/");
});
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
await waitForDemoReady(page);
await loadDemo(page);
expectNoPageErrors(pageErrors);
});
test("dashboard renders Lovelace cards", async ({ page }) => {
await waitForDemoReady(page);
await loadDemo(page);
await expect(page.locator(demoCardSelector).first()).toBeVisible({
timeout: PANEL_TIMEOUT,
@@ -38,7 +40,7 @@ test.describe("Home Assistant Demo", () => {
});
test("sidebar navigation changes the active panel", async ({ page }) => {
await waitForDemoReady(page);
await loadDemo(page);
await openDemoSidebar(page);
await activateDemoSidebarPanel(page, "map");
@@ -48,7 +50,7 @@ test.describe("Home Assistant Demo", () => {
test("clicking an entity card opens the more-info dialog", async ({
page,
}) => {
await waitForDemoReady(page);
await loadDemo(page);
// Tile cards are the most common card type in the demo; fall back to other
// clickable card types in case this platform renders a different layout.
@@ -65,4 +67,65 @@ test.describe("Home Assistant Demo", () => {
expectNoPageErrors(pageErrors);
});
for (const colorScheme of ["light", "dark"] as const) {
test(`unset theme remains light with ${colorScheme} system color scheme`, async ({
page,
}) => {
await page.emulateMedia({ colorScheme });
await page.addInitScript(() => {
localStorage.removeItem("demo_theme");
localStorage.removeItem("selectedTheme");
});
await loadDemo(page);
await expectDemoDarkMode(page, false);
expectNoPageErrors(pageErrors);
});
}
test("theme selection persists without offering migration", async ({
page,
}) => {
await page.addInitScript(() => {
if (sessionStorage.getItem("theme_test_seeded")) {
return;
}
sessionStorage.setItem("theme_test_seeded", "true");
localStorage.removeItem("demo_theme");
localStorage.setItem(
"selectedTheme",
JSON.stringify({ theme: "default", dark: false })
);
});
await loadDemo(page, "/#/profile/general");
const themeRow = page.locator("ha-pick-theme-row");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(themeRow.locator(":scope > ha-settings-row")).toHaveCount(0);
await themeRow.locator('ha-radio-option[value="dark"]').click();
await expectStoredDemoTheme(page, { theme: "default", dark: true });
await expect
.poll(() => page.evaluate(() => localStorage.getItem("selectedTheme")), {
timeout: SHELL_TIMEOUT,
})
.toBeNull();
await expectDemoDarkMode(page, true);
await reloadDemo(page);
await expectStoredDemoTheme(page, { theme: "default", dark: true });
await expectDemoDarkMode(page, true);
await loadDemo(page, "/#/profile/general");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(
themeRow.locator('ha-radio-option[value="dark"]')
).toHaveAttribute("aria-checked", "true");
expectNoPageErrors(pageErrors);
});
});
+44
View File
@@ -1,6 +1,9 @@
import { expect, type Page } from "@playwright/test";
import type { ThemeSettings } from "../../../src/types";
import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers";
const DEMO_THEME_STORAGE_KEY = "demo_theme";
export const demoCardSelector = [
"hui-tile-card",
"hui-entity-card",
@@ -21,6 +24,47 @@ export async function waitForDemoReady(page: Page) {
});
}
export async function loadDemo(page: Page, path = "/") {
await page.goto(path);
await waitForDemoReady(page);
}
export async function reloadDemo(page: Page) {
await page.reload();
await waitForDemoReady(page);
}
export async function expectDemoDarkMode(page: Page, darkMode: boolean) {
await expect
.poll(
() =>
page.locator("ha-demo").evaluate((element) => {
const demo = element as HTMLElement & {
hass?: { themes?: { darkMode?: boolean } };
};
return demo.hass?.themes?.darkMode;
}),
{ timeout: SHELL_TIMEOUT }
)
.toBe(darkMode);
}
export async function expectStoredDemoTheme(
page: Page,
expected: ThemeSettings
) {
await expect
.poll(
() =>
page.evaluate((storageKey) => {
const storedTheme = localStorage.getItem(storageKey);
return storedTheme ? (JSON.parse(storedTheme) as unknown) : null;
}, DEMO_THEME_STORAGE_KEY),
{ timeout: SHELL_TIMEOUT }
)
.toEqual(expected);
}
export async function openDemoSidebar(page: Page) {
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
+2 -2
View File
@@ -175,14 +175,14 @@ describe("getOnboardingSurveyUrl", () => {
onboarded_version: "2026.7.0",
onboarded_date: "2026-07-14T12:00:00.000Z",
}),
"https://www.home-assistant.io/surveys/onboarding?version=2026.7.0"
"https://ohf.to/ha/surveys/onboarding?version=2026.7.0"
);
});
it("omits a missing version param", () => {
assert.strictEqual(
getOnboardingSurveyUrl({}),
"https://www.home-assistant.io/surveys/onboarding"
"https://ohf.to/ha/surveys/onboarding"
);
});
});