Compare commits

...
1 Commits
Author SHA1 Message Date
Bruno PantaleãoandClaude Opus 5 b52f72ad47 Let kiosk clients choose which parts of the UI are hidden
Kiosk mode was a single boolean: a client could ask for kiosk mode and got
one fixed bundle of hidden UI, with no say over what it hid. The iOS app
enables it purely to suppress the hamburger for the native tab bar and
inherits the hidden add, search and edit buttons as a side effect.

Name the hideable parts in KIOSK_ELEMENTS and let `kiosk_mode/set` carry
either `excluded_elements` (hide exactly these) or `included_elements`
(hide everything but these). Sending neither keeps the set kiosk mode hid
before, so clients that only send `enable` are unaffected; sending both
fails the command, since the two lists describe the same set and a message
carrying both has no single meaning.

Two parts are newly hideable rather than newly configurable: the Assist
button, previously the only toolbar button kiosk mode never touched, and
the view tabs, which give way to the current view's title. Neither is in
the default set.

The app panel now reads the kiosk state during render instead of caching
it on a hass change, so opening an ingress add-on while kiosk mode is
already on hides its header on the first paint rather than never.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DES4YqEAru3pxeHtUrTtmj
2026-09-13 16:01:51 +00:00
14 changed files with 235 additions and 24 deletions
+2
View File
@@ -10,6 +10,7 @@ import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_eleme
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../../src/data/kiosk_mode";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
@@ -633,6 +634,7 @@ class HaGallery extends LitElement {
floors: {},
hassUrl: (path) => path,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
language: "en",
loadBackendTranslation: async () => galleryLocalize,
loadFragmentTranslation: async () => undefined,
+2 -1
View File
@@ -93,7 +93,8 @@ class HaMenuButton extends LitElement {
}
const showButton =
this._ui?.kioskMode === false &&
!!this._ui &&
!this._ui.kioskElementsHidden.has("sidebar_button") &&
(this._narrow || this._ui.dockedSidebar === "always_hidden");
this._show = showButton || this._alwaysVisible;
+2
View File
@@ -115,6 +115,7 @@ const updateUi = (
value.panelUrl !== hass.panelUrl ||
value.dockedSidebar !== hass.dockedSidebar ||
value.kioskMode !== hass.kioskMode ||
value.kioskElementsHidden !== hass.kioskElementsHidden ||
value.enableShortcuts !== hass.enableShortcuts ||
value.vibrate !== hass.vibrate ||
value.suspendWhenHidden !== hass.suspendWhenHidden
@@ -126,6 +127,7 @@ const updateUi = (
panelUrl: hass.panelUrl,
dockedSidebar: hass.dockedSidebar,
kioskMode: hass.kioskMode,
kioskElementsHidden: hass.kioskElementsHidden,
enableShortcuts: hass.enableShortcuts,
vibrate: hass.vibrate,
suspendWhenHidden: hass.suspendWhenHidden,
+87
View File
@@ -0,0 +1,87 @@
/**
* The parts of the UI a kiosk client can hide.
*
* A client names them over the external bus (`kiosk_mode/set`) as either
* `excluded_elements` (hide exactly these) or `included_elements` (hide
* everything except these), so the same set can be described from either side.
*/
export const KIOSK_ELEMENTS = [
/** The sidebar itself: never docked, only reachable as an overlay. */
"sidebar",
/** The hamburger button that opens the sidebar, in every panel's toolbar. */
"sidebar_button",
/** The dashboard view tabs. The current view's title is shown instead. */
"dashboard_tabs",
/** The "+" menu that adds a device, automation, area or person. */
"dashboard_add_button",
/** The search button that opens the quick bar. */
"dashboard_search_button",
/** The Assist button. */
"dashboard_assist_button",
/** The pencil that switches the dashboard into edit mode. */
"dashboard_edit_button",
/** The header the app panel draws above an ingress add-on iframe. */
"app_panel_header",
] as const;
export type KioskElement = (typeof KIOSK_ELEMENTS)[number];
const KIOSK_ELEMENTS_SET: ReadonlySet<string> = new Set(KIOSK_ELEMENTS);
/**
* Nothing hidden. Shared instance so context consumers that compare by
* reference don't see a change every time kiosk mode is switched off.
*/
export const NO_KIOSK_ELEMENTS_HIDDEN: ReadonlySet<KioskElement> = new Set();
/**
* What a client gets when it enables kiosk mode without naming any elements.
* This is the set kiosk mode hid back when it was a single boolean, so a client
* that only sends `enable` keeps the behavior it has today.
*/
export const DEFAULT_KIOSK_ELEMENTS_HIDDEN: ReadonlySet<KioskElement> = new Set(
[
"sidebar",
"sidebar_button",
"dashboard_add_button",
"dashboard_search_button",
"dashboard_edit_button",
"app_panel_header",
]
);
export interface KioskModeParams {
enable: boolean;
/** Hide exactly these. Mutually exclusive with `includedElements`. */
excludedElements?: readonly string[];
/** Hide everything except these. Mutually exclusive with `excludedElements`. */
includedElements?: readonly string[];
}
const isKioskElement = (value: string): value is KioskElement =>
KIOSK_ELEMENTS_SET.has(value);
/**
* Resolve a kiosk request into the elements to hide.
*
* Element names this frontend doesn't know are ignored rather than rejected, so
* a newer client naming an element an older frontend has never heard of still
* gets the rest of its request honored.
*/
export const resolveKioskElementsHidden = ({
enable,
excludedElements,
includedElements,
}: KioskModeParams): ReadonlySet<KioskElement> => {
if (!enable) {
return NO_KIOSK_ELEMENTS_HIDDEN;
}
if (excludedElements) {
return new Set(excludedElements.filter(isKioskElement));
}
if (includedElements) {
const shown = new Set(includedElements);
return new Set(KIOSK_ELEMENTS.filter((element) => !shown.has(element)));
}
return DEFAULT_KIOSK_ELEMENTS_HIDDEN;
};
+21 -1
View File
@@ -98,7 +98,27 @@ export const handleExternalMessage = (
} else if (msg.command === "bar_code/aborted") {
barCodeListeners.forEach((listener) => listener(msg));
} else if (msg.command === "kiosk_mode/set") {
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
const { enable, excluded_elements, included_elements } = msg.payload;
// The two lists are two ways of describing the same set, so a message
// carrying both has no single meaning we could act on.
if (excluded_elements && included_elements) {
bus.fireMessage({
id: msg.id,
type: "result",
success: false,
error: {
code: "invalid_format",
message:
"Pass either excluded_elements or included_elements, not both",
},
});
return true;
}
fireEvent(window, "hass-kiosk-mode", {
enable,
excludedElements: excluded_elements,
includedElements: included_elements,
});
} else {
return false;
}
+11
View File
@@ -1,5 +1,6 @@
import type { NavigateOptions } from "../common/navigate";
import type { AutomationConfig } from "../data/automation";
import type { KioskElement } from "../data/kiosk_mode";
const CALLBACK_EXTERNAL_BUS = "externalBus";
@@ -327,6 +328,16 @@ export interface EMIncomingMessageKioskModeSet {
command: "kiosk_mode/set";
payload: {
enable: boolean;
/**
* Hide exactly these parts of the UI. Mutually exclusive with
* `included_elements`; sending both fails the command.
*/
excluded_elements?: KioskElement[];
/**
* Hide every part of the UI except these. Mutually exclusive with
* `excluded_elements`; sending both fails the command.
*/
included_elements?: KioskElement[];
};
}
+2
View File
@@ -24,6 +24,7 @@ import {
} from "../data/context";
import { updateHassGroups } from "../data/context/updateContext";
import type { IconCategory } from "../data/icons";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../data/kiosk_mode";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import {
DateFormat,
@@ -464,6 +465,7 @@ export const provideHass = (
vibrate: true,
debugConnection: false,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
suspendWhenHidden: false,
// @ts-ignore
async callService(domain, service, data, target, _notify, returnResponse) {
+7 -3
View File
@@ -51,7 +51,9 @@ export class HomeAssistantMain extends LitElement {
protected render(): TemplateResult {
const sidebarNarrow =
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode;
this._sidebarNarrow ||
this._externalSidebar ||
this.hass.kioskElementsHidden.has("sidebar");
const isPanelReady =
this.hass.panels && this.hass.userData && this.hass.systemData;
@@ -105,7 +107,7 @@ export class HomeAssistantMain extends LitElement {
});
return;
}
if (this._sidebarNarrow || this.hass.kioskMode) {
if (this._sidebarNarrow || this.hass.kioskElementsHidden.has("sidebar")) {
this._drawerOpen = ev.detail?.open ?? !this._drawerOpen;
} else {
fireEvent(this, "hass-dock-sidebar", {
@@ -144,7 +146,9 @@ export class HomeAssistantMain extends LitElement {
this.toggleAttribute(
"modal",
this._sidebarNarrow || this._externalSidebar || this.hass.kioskMode
this._sidebarNarrow ||
this._externalSidebar ||
this.hass.kioskElementsHidden.has("sidebar")
);
}
+4 -9
View File
@@ -49,8 +49,6 @@ class HaPanelApp extends LitElement {
@state() private _loadingMessage?: string;
@state() private _kioskMode = false;
@state() private _iframeLoaded = false;
// Set when the addon signals (via subscribe-properties) that it handles the
@@ -83,11 +81,6 @@ class HaPanelApp extends LitElement {
) {
this._sendPropertiesToIframe();
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass && oldHass.kioskMode !== this.hass.kioskMode) {
this._kioskMode = this.hass.kioskMode;
}
}
public connectedCallback() {
@@ -121,10 +114,12 @@ class HaPanelApp extends LitElement {
></hass-loading-screen>`;
}
const hideHeader = this.hass.kioskElementsHidden.has("app_panel_header");
// Make sure this all is 1 template so hiding toolbar doesn't reload iframe
return html`
${
!this._kioskMode &&
!hideHeader &&
(this.narrow || this.hass.dockedSidebar === "always_hidden")
? html`
<div class="header">
@@ -141,7 +136,7 @@ class HaPanelApp extends LitElement {
<iframe
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": this._kioskMode,
"kiosk-mode": hideHeader,
"handle-safe-area": this._handleSafeArea,
})}
title=${this._addon.name}
+21 -8
View File
@@ -257,7 +257,9 @@ class HUIRoot extends LitElement {
icon: mdiPlus,
key: "ui.panel.lovelace.menu.add",
visible:
!this._editMode && this.hass.user?.is_admin && !this.hass.kioskMode,
!this._editMode &&
this.hass.user?.is_admin &&
!this.hass.kioskElementsHidden.has("dashboard_add_button"),
overflow: this.narrow,
subItems: [
{
@@ -300,7 +302,9 @@ class HUIRoot extends LitElement {
? "(⌘ + K)"
: "(Ctrl + K)"
: undefined,
visible: !this._editMode && !this.hass.kioskMode,
visible:
!this._editMode &&
!this.hass.kioskElementsHidden.has("dashboard_search_button"),
overflow: this.narrow,
},
{
@@ -311,7 +315,9 @@ class HUIRoot extends LitElement {
suffix:
this.hass.enableShortcuts && !isMobileClient ? "(A)" : undefined,
visible:
!this._editMode && this._conversation(this.hass.config.components),
!this._editMode &&
this._conversation(this.hass.config.components) &&
!this.hass.kioskElementsHidden.has("dashboard_assist_button"),
overflow: this.narrow,
},
{
@@ -347,7 +353,7 @@ class HUIRoot extends LitElement {
!this._editMode &&
this.hass!.user?.is_admin &&
!this.hass!.config.recovery_mode &&
!this.hass.kioskMode &&
!this.hass.kioskElementsHidden.has("dashboard_edit_button") &&
!this.noEdit,
overflow: true,
overflow_can_promote: true,
@@ -549,6 +555,7 @@ class HUIRoot extends LitElement {
const isSubview = curViewConfig?.subview;
const hasTabViews = views.filter((view) => !view.subview).length > 1;
const hideTabs = this.hass.kioskElementsHidden.has("dashboard_tabs");
return html`
<div
@@ -605,13 +612,19 @@ class HUIRoot extends LitElement {
${curViewConfig.title}
</div>
`
: hasTabViews
? tabs
: html`
: hideTabs
? html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
${curViewConfig?.title ?? dashboardTitle}
</div>
`
: hasTabViews
? tabs
: html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
</div>
`
}
<div class="action-items">
${this._renderActionItems()}
+2
View File
@@ -18,6 +18,7 @@ import {
subscribeFrontendUserData,
} from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { NO_KIOSK_ELEMENTS_HIDDEN } from "../data/kiosk_mode";
import { serviceCallWillDisconnect } from "../data/service";
import {
DateFormat,
@@ -85,6 +86,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
localize: () => "",
translationMetadata,
kioskMode: false,
kioskElementsHidden: NO_KIOSK_ELEMENTS_HIDDEN,
dockedSidebar: "docked",
vibrate: true,
debugConnection: __DEV__,
+7 -2
View File
@@ -1,5 +1,7 @@
import type { PropertyValues } from "lit";
import type { HASSDomEvent } from "../common/dom/fire_event";
import type { KioskModeParams } from "../data/kiosk_mode";
import { resolveKioskElementsHidden } from "../data/kiosk_mode";
import type { Constructor, HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage";
import type { HassBaseEl } from "./hass-base-mixin";
@@ -12,7 +14,7 @@ declare global {
// for fire event
interface HASSDomEvents {
"hass-dock-sidebar": DockSidebarParams;
"hass-kiosk-mode": { enable: boolean };
"hass-kiosk-mode": KioskModeParams;
}
// for add event listener
interface HTMLElementEventMap {
@@ -32,7 +34,10 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
storeState(this.hass!);
});
window.addEventListener("hass-kiosk-mode", (ev) => {
this._updateHass({ kioskMode: ev.detail.enable });
this._updateHass({
kioskMode: ev.detail.enable,
kioskElementsHidden: resolveKioskElementsHidden(ev.detail),
});
});
}
};
+3
View File
@@ -18,6 +18,7 @@ import type { AreaRegistryEntry } from "./data/area/area_registry";
import type { DeviceRegistryEntry } from "./data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "./data/entity/entity_registry";
import type { FloorRegistryEntry } from "./data/floor_registry";
import type { KioskElement } from "./data/kiosk_mode";
import type {
CoreFrontendSystemData,
CoreFrontendUserData,
@@ -305,6 +306,8 @@ export interface HomeAssistantUI {
panelUrl: string;
dockedSidebar: "docked" | "always_hidden" | "auto";
kioskMode: boolean;
/** Parts of the UI a kiosk client asked to hide. Empty when kiosk mode is off. */
kioskElementsHidden: ReadonlySet<KioskElement>;
enableShortcuts: boolean;
vibrate: boolean;
suspendWhenHidden: boolean;
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { KioskElement } from "../../src/data/kiosk_mode";
import {
DEFAULT_KIOSK_ELEMENTS_HIDDEN,
KIOSK_ELEMENTS,
NO_KIOSK_ELEMENTS_HIDDEN,
resolveKioskElementsHidden,
} from "../../src/data/kiosk_mode";
const hidden = (params: Parameters<typeof resolveKioskElementsHidden>[0]) =>
[...resolveKioskElementsHidden(params)].sort();
describe("resolveKioskElementsHidden", () => {
it("hides nothing when kiosk mode is off, whatever the client listed", () => {
expect(
resolveKioskElementsHidden({
enable: false,
excludedElements: ["sidebar"],
})
).toBe(NO_KIOSK_ELEMENTS_HIDDEN);
});
it("falls back to the default set when no elements are named", () => {
expect(resolveKioskElementsHidden({ enable: true })).toBe(
DEFAULT_KIOSK_ELEMENTS_HIDDEN
);
});
it("hides exactly the excluded elements", () => {
expect(
hidden({
enable: true,
excludedElements: ["sidebar_button", "dashboard_edit_button"],
})
).toEqual(["dashboard_edit_button", "sidebar_button"]);
});
it("hides everything but the included elements", () => {
const shown: KioskElement[] = ["sidebar", "dashboard_tabs"];
expect(hidden({ enable: true, includedElements: shown })).toEqual(
KIOSK_ELEMENTS.filter((element) => !shown.includes(element)).sort()
);
});
it("hides everything when the included list is empty", () => {
expect(hidden({ enable: true, includedElements: [] })).toEqual(
[...KIOSK_ELEMENTS].sort()
);
});
// A newer client may name elements this frontend has never heard of; the rest
// of its request still has to be honored.
it("ignores unknown element names in either list", () => {
expect(
hidden({ enable: true, excludedElements: ["sidebar", "from_the_future"] })
).toEqual(["sidebar"]);
expect(
hidden({
enable: true,
includedElements: [...KIOSK_ELEMENTS, "from_the_future"],
})
).toEqual([]);
});
});