feat: add complete German localization

Add Deutsch across all card surfaces and backend flows, backed by the language registry introduced in #62. German loads as a fingerprint-checked page-shared locale chunk so EN/RU remain synchronous and the initial View budget stays intact. Root render gates prevent mixed-language flashes, retry once, and fail open to English. Extend parity, runtime, bundle, browser and visual coverage, plus contributor and user documentation.

Issue: #348
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 11:58:46 +03:00
parent 7a0702adea
commit 12cd8a1af3
50 changed files with 2265 additions and 524 deletions
+8 -2
View File
@@ -1,7 +1,8 @@
/** Card configuration editor (Lovelace GUI). */
import { LitElement, html, nothing } from 'lit';
import { LitElement, html, nothing, noChange } from 'lit';
import { langOf, t, type Lang } from './i18n';
import { languageOptions } from './i18n/registry';
import { LANGUAGE_RUNTIME, languageOptions } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { invalidDefaultFloor } from './card-editor-validation';
class HouseplanCardEditor extends LitElement {
@@ -113,6 +114,11 @@ class HouseplanCardEditor extends LitElement {
protected render() {
if (!this.hass || !this._config) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
this._loadSpaces();
const L = this._lang;
const labels: Record<string, string> = {
+10 -3
View File
@@ -6,7 +6,7 @@
* 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas.
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
*/
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues } from 'lit';
import { guard } from 'lit/directives/guard.js';
import { repeat } from 'lit/directives/repeat.js';
import './hp-dialog';
@@ -232,6 +232,8 @@ import {
} from './coordinate-canonicalization';
import { enqueueSerializedWrite } from './serialized-write-queue';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { LANGUAGE_RUNTIME } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { CommandStack } from './command-stack';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
@@ -10700,7 +10702,13 @@ export class HouseplanCard extends LitElement {
}
}
protected render(): TemplateResult | typeof nothing {
protected render(): TemplateResult | typeof nothing | typeof noChange {
if (!this._config || !this.hass) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
const onboardingRuntimeRequested = !!this._importDialog
|| !!(this._spaceDialog && this._spaceDialogUsesOnboardingRuntime(this._spaceDialog.mode));
const editorRuntimeRequested = this._mode !== 'view'
@@ -10717,7 +10725,6 @@ export class HouseplanCard extends LitElement {
void this._ensureOnboardingRuntime();
}
if (editorRuntimeRequested && !this._editorRuntime) void this._ensureEditorRuntime();
if (!this._config || !this.hass) return nothing;
const model = this._model;
const diagnostics = this.houseplanDiagnostics();
const fixed = this._fixedFloorState(model);
+4 -4
View File
@@ -9,7 +9,7 @@ import {
FALLBACK_DICTIONARY,
FALLBACK_LANGUAGE_CODE,
LANGUAGE_REGISTRY,
languageEntry,
dictionaryFor,
resolveLanguageCode,
type Lang,
} from './i18n/registry';
@@ -30,13 +30,13 @@ export function langOf(hass: any, configLang?: string | null): Lang {
/** Translate a key with optional {placeholder} substitution. */
export function t(lang: Lang, key: Key, vars?: Record<string, string | number>): string {
const dictionary = languageEntry(lang)?.dictionary;
return subst(dictionary?.[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
const dictionary = dictionaryFor(lang);
return subst(dictionary[key] ?? FALLBACK_DICTIONARY[key] ?? key, vars);
}
/** Whether a localized value exists and contains useful text after fallback. */
export function hasTranslation(lang: Lang, key: string): boolean {
const value = languageEntry(lang)?.dictionary[key] ?? FALLBACK_DICTIONARY[key];
const value = dictionaryFor(lang)[key] ?? FALLBACK_DICTIONARY[key];
return typeof value === 'string' && value.trim().length > 0;
}
+1061
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
import dictionary from './de.json' with { type: 'json' };
/** Must match the entry bundle before a lazy locale can be committed. */
export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
export { dictionary };
-20
View File
@@ -71,8 +71,6 @@
"title.zoom_in": "Zoom in",
"title.zoom_out": "Zoom out",
"title.zoom_fit": "Fit all",
"title.add_device": "Add a device to the plan",
"title.show_all": "Show hidden and disabled devices as service markers (this tab only)",
"title.markup": "Room markup: grid, lines, outlines",
"title.configure_space": "Configure space",
"title.add_space": "Add space",
@@ -127,7 +125,6 @@
"history.decor_transform": "Transform decor object",
"history.decor_delete": "Delete decor object",
"history.backdrop_transform": "Transform plan backdrop",
"title.markup_opening": "Openings: click a wall to place, click an opening to edit",
"opening.new": "New opening",
"opening.edit": "Opening",
"opening.door": "Door",
@@ -212,8 +209,6 @@
"marker.preview.multiple_sources": "{n} sources",
"marker.preview.more_sources": "+{n} more",
"marker.preview.scaled": "The preview is scaled to {n}% to fit. The saved size is unchanged.",
"marker.preview.demo_activity": "Show activity example",
"marker.preview.demo_notice": "Temporary activity example; the actual device state is unchanged.",
"marker.preview.demo_short": "Show short activity",
"marker.preview.demo_continuous": "Show continuous activity",
"marker.preview.stop_continuous": "Stop continuous activity",
@@ -294,7 +289,6 @@
"confirm.delete_room": "Delete room \"{name}\"?",
"confirm.delete_room_title": "Delete room \"{name}\"?",
"confirm.delete_room_body": "Choose whether the room's exclusive physical walls should remain. Walls shared with another room are always kept.",
"confirm.merge_rooms": "Merge rooms \"{a}\" and \"{b}\"?",
"confirm.remove_marker": "Delete \"{name}\" from the plan? The device will disappear completely and stop contributing to plan data. You can add it again later.",
"confirm.erase_decor": "Erase the {kind} object? You can undo this action from the editor history.",
"confirm.delete_space": "Delete space \"{title}\" with all its rooms and markup?",
@@ -311,7 +305,6 @@
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
"toast.contour_min_edges": "Draw at least two edges before closing the outline",
"toast.contour_cannot_close": "The outline cannot close because it is degenerate or intersects itself",
"toast.merge_not_adjacent": "Only rooms that share a wall can be merged",
"toast.rooms_merged": "Rooms merged into “{name}”",
@@ -321,7 +314,6 @@
"merge.hint": "The merged room keeps one name and one area. The other area is released — its devices leave the plan until another room claims it.",
"merge.keep": "Keep",
"merge.no_area": "no area",
"room.split_header": "New room from the split",
"toast.room_saved": "Room saved ({n}). Devices added: {added}. Outline the next one or exit markup.",
"toast.room_saved_no_area": "Room saved ({n}, no area). Outline the next one or exit markup.",
"toast.marker_needs_server": "Device editing is available after the config is moved to the server",
@@ -361,8 +353,6 @@
"editor.show_signal": "Show zigbee signal (LQI)",
"editor.language": "Interface language",
"editor.lang_auto": "Auto (HA profile)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Icon rules: which MDI icon devices get by name",
"rules.title": "Icon rules",
"rules.hint": "Rules are checked top-down against “device name + model” (case-insensitive regex); the first match wins. When nothing matches, the entity device class decides, then the generic chip icon.",
@@ -407,13 +397,7 @@
"space.custom_fill": "Fill color",
"space.source_file": "I have a floor-plan image",
"space.source_draw": "No image — I'll outline rooms by hand",
"space.orientation": "Canvas",
"orient.landscape": "Landscape",
"orient.portrait": "Portrait",
"orient.square": "Square",
"fill.temp": "Temperature",
"space.temp_min": "Comfort from",
"space.temp_max": "to",
"tip.temp_avg": "average temperature:",
"tip.hum_avg": "average humidity:",
"space_card.button": "Open the space plan",
@@ -457,8 +441,6 @@
"opening.lock_action": "Lock",
"opening.lock_pending": "Working…",
"title.close_editor": "Close editor (back to view)",
"devbar.add": "Add",
"devbar.show_all": "Hidden and disabled",
"devbar.rules": "Icon rules",
"device_inbox.button": "Devices",
"device_inbox.title": "Devices on the plan",
@@ -734,7 +716,6 @@
"marker.ha_registry_limited": "The full Home Assistant registry is unavailable to this user. The unverified object cannot be shown or used for now.",
"marker.delete_tip": "Completely delete the device from the plan and every aggregate. You can add it again later.",
"tap.run": "Run automation/script/scene",
"tap.cover": "Open/close (curtains/blinds)",
"marker.run_target_label": "What to run",
"marker.run_search_ph": "Search: automation, script or scene…",
"marker.run_target_gone": "Target {id} not found — pick again",
@@ -772,7 +753,6 @@
"run.scene": "scene",
"confirm.tap_run": "Run \"{name}\"?",
"confirm.tap_toggle": "Toggle \"{name}\"?",
"confirm.tap_cover": "Open/close \"{name}\"?",
"confirm.current_state": "Current state: {state}",
"confirm.expected_state": "After switching: {state}",
"confirm.group_current": "on {on} of {total}",
+139
View File
@@ -0,0 +1,139 @@
import { html, type TemplateResult } from 'lit';
export type LocaleDictionary = Record<string, string>;
export interface LazyLanguageModule {
dictionary: LocaleDictionary;
fingerprint: string;
}
export interface RuntimeLanguageEntry {
code: string;
dictionary?: LocaleDictionary;
loadDictionary?: (attempt: 0 | 1) => Promise<LazyLanguageModule>;
}
export type LanguageLoadState = 'ready' | 'pending' | 'fallback';
export interface LanguageRuntimeContract {
state(code: string): LanguageLoadState;
dictionary(code: string): LocaleDictionary | undefined;
ensure(code: string): Promise<void>;
}
/**
* Page-scoped dictionary cache shared by all House Plan cards and editors.
*
* `fallback` is a settled state: a failed optional locale must never leave a
* card behind an endless loading surface. The synchronous translation helper
* then uses English in the ordinary way.
*/
export class LanguageRuntime {
private readonly dictionaries = new Map<string, LocaleDictionary>();
private readonly pending = new Map<string, Promise<void>>();
private readonly failed = new Set<string>();
public constructor(
private readonly entries: readonly RuntimeLanguageEntry[],
private readonly expectedFingerprint: string,
private readonly warn: (message: string, error: unknown) => void = console.warn,
) {
for (const entry of entries) {
if (entry.dictionary) this.dictionaries.set(entry.code, entry.dictionary);
}
}
public state(code: string): LanguageLoadState {
if (this.dictionaries.has(code)) return 'ready';
if (this.failed.has(code)) return 'fallback';
return 'pending';
}
public dictionary(code: string): LocaleDictionary | undefined {
return this.dictionaries.get(code);
}
public ensure(code: string): Promise<void> {
if (this.state(code) !== 'pending') return Promise.resolve();
const existing = this.pending.get(code);
if (existing) return existing;
const entry = this.entries.find((candidate) => candidate.code === code);
const task = this.load(entry).finally(() => this.pending.delete(code));
this.pending.set(code, task);
return task;
}
private async load(entry: RuntimeLanguageEntry | undefined): Promise<void> {
if (!entry?.loadDictionary) {
if (entry) this.failed.add(entry.code);
return;
}
let lastError: unknown;
for (const attempt of [0, 1] as const) {
try {
const loaded = await entry.loadDictionary(attempt);
if (loaded.fingerprint !== this.expectedFingerprint) {
throw new Error(`locale fingerprint mismatch for ${entry.code}`);
}
this.dictionaries.set(entry.code, loaded.dictionary);
this.failed.delete(entry.code);
return;
} catch (error) {
lastError = error;
}
}
this.failed.add(entry.code);
this.warn(`[houseplan] unable to load ${entry.code} locale; using English`, lastError);
}
}
interface LanguageHostElement {
inert: boolean;
isConnected: boolean;
requestUpdate(): void;
hasAttribute(name: string): boolean;
setAttribute(name: string, value: string): void;
removeAttribute(name: string): void;
}
const committedHosts = new WeakSet<LanguageHostElement>();
const localeInertHosts = new WeakSet<LanguageHostElement>();
const localeBusyHosts = new WeakSet<LanguageHostElement>();
/** Lightweight production render gate; generic controller below remains testable in isolation. */
export function languageRenderGate(
host: LanguageHostElement,
runtime: LanguageRuntimeContract,
code: string | null,
): LanguageRenderGate {
const state = code ? runtime.state(code) : 'ready';
if (!code || state !== 'pending') {
if (localeInertHosts.delete(host)) host.inert = false;
if (localeBusyHosts.delete(host)) host.removeAttribute('aria-busy');
if (code) {
host.setAttribute('lang', state === 'fallback' ? 'en' : code);
committedHosts.add(host);
}
return 'ready';
}
if (!host.inert) {
host.inert = true;
localeInertHosts.add(host);
}
if (!host.hasAttribute('aria-busy')) {
host.setAttribute('aria-busy', 'true');
localeBusyHosts.add(host);
}
void runtime.ensure(code).then(() => {
if (host.isConnected) host.requestUpdate();
});
return committedHosts.has(host) ? 'warm' : 'cold';
}
export type LanguageRenderGate = 'ready' | 'cold' | 'warm';
/** Keep a root Lit surface inert and visually stable while its lazy locale loads. */
/** A language-neutral first frame: no fallback-language copy is allowed to flash. */
export function languageLoadingTemplate(): TemplateResult {
return html`<ha-circular-progress active role="status" aria-busy="true"></ha-circular-progress>`;
}
+62 -1
View File
@@ -1,10 +1,26 @@
import en from './en.json' with { type: 'json' };
import ru from './ru.json' with { type: 'json' };
import type {
LanguageRuntimeContract,
LazyLanguageModule,
LocaleDictionary,
} from './language-runtime';
const BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const GERMAN_RETRY_ASSET = '__HOUSEPLAN_DE_RETRY_ASSET__';
export interface LanguageEntry {
code: string;
nativeLabel: string;
dictionary: Record<string, string>;
dictionary?: LocaleDictionary;
loadDictionary?: (attempt: 0 | 1) => Promise<LazyLanguageModule>;
}
async function loadGerman(attempt: 0 | 1): Promise<LazyLanguageModule> {
const module = attempt === 0
? await import('./de')
: await import(/* @vite-ignore */ new URL(`${GERMAN_RETRY_ASSET}?retry`, import.meta.url).href);
return { dictionary: module.dictionary, fingerprint: module.fingerprint };
}
/**
@@ -16,6 +32,7 @@ export interface LanguageEntry {
export const LANGUAGE_REGISTRY = [
{ code: 'en', nativeLabel: 'English', dictionary: en },
{ code: 'ru', nativeLabel: 'Русский', dictionary: ru },
{ code: 'de', nativeLabel: 'Deutsch', loadDictionary: loadGerman },
] as const satisfies readonly LanguageEntry[];
export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
@@ -23,6 +40,50 @@ export type Lang = (typeof LANGUAGE_REGISTRY)[number]['code'];
export const FALLBACK_LANGUAGE_CODE: Lang = 'en';
export const FALLBACK_DICTIONARY = en;
/** One page-scoped cache: multiple cards share the same locale request. */
let germanDictionary: LocaleDictionary | undefined;
let germanPending: Promise<void> | undefined;
let germanFailed = false;
async function settleGerman(): Promise<void> {
let lastError: unknown;
for (const attempt of [0, 1] as const) {
try {
const loaded = await loadGerman(attempt);
if (loaded.fingerprint !== BUILD_FINGERPRINT) throw new Error('locale fingerprint mismatch');
germanDictionary = loaded.dictionary;
return;
} catch (error) {
lastError = error;
}
}
germanFailed = true;
console.warn('[houseplan] unable to load de locale; using English', lastError);
}
export const LANGUAGE_RUNTIME: LanguageRuntimeContract = {
state: (code) => code !== 'de' || germanDictionary
? 'ready' : germanFailed ? 'fallback' : 'pending',
dictionary: (code) => code === 'de'
? germanDictionary : code === 'ru' ? ru : code === 'en' ? en : undefined,
ensure: (code) => {
if (code !== 'de' || germanDictionary || germanFailed) return Promise.resolve();
return germanPending ??= settleGerman().finally(() => { germanPending = undefined; });
},
};
/** Return a loaded dictionary, falling back synchronously to English. */
export function dictionaryFor(value: unknown): LocaleDictionary {
const code = languageEntry(value)?.code ?? FALLBACK_LANGUAGE_CODE;
return LANGUAGE_RUNTIME.dictionary(code) ?? FALLBACK_DICTIONARY;
}
/** Ensure a lazy locale has settled (success or bounded English fallback). */
export function ensureLanguage(value: unknown): Promise<void> {
const code = languageEntry(value)?.code ?? FALLBACK_LANGUAGE_CODE;
return LANGUAGE_RUNTIME.ensure(code);
}
/** Normalize HA locale tags for registry lookup. */
export function normalizeLanguageTag(value: unknown): string {
return typeof value === 'string'
-20
View File
@@ -71,8 +71,6 @@
"title.zoom_in": "Приблизить",
"title.zoom_out": "Отдалить",
"title.zoom_fit": "Вписать всё",
"title.add_device": "Добавить устройство на план",
"title.show_all": "Показать скрытые и деактивированные устройства служебными маркерами (только в этой вкладке)",
"title.markup": "Разметка комнат: сетка, линии, контуры",
"title.configure_space": "Настроить пространство",
"title.add_space": "Добавить пространство",
@@ -127,7 +125,6 @@
"history.decor_transform": "Трансформация объекта декора",
"history.decor_delete": "Удаление объекта декора",
"history.backdrop_transform": "Трансформация подложки плана",
"title.markup_opening": "Проёмы: клик по стене — добавить, клик по проёму — редактировать",
"opening.new": "Новый проём",
"opening.edit": "Проём",
"opening.door": "Дверь",
@@ -212,8 +209,6 @@
"marker.preview.multiple_sources": "Источников: {n}",
"marker.preview.more_sources": "ещё {n}",
"marker.preview.scaled": "Предпросмотр уменьшен до {n}%, чтобы поместиться. Сохранённый размер не изменится.",
"marker.preview.demo_activity": "Показать пример активности",
"marker.preview.demo_notice": "Временный пример активности; фактическое состояние устройства не меняется.",
"marker.preview.demo_short": "Показать краткую активность",
"marker.preview.demo_continuous": "Показать постоянную активность",
"marker.preview.stop_continuous": "Остановить постоянную активность",
@@ -294,7 +289,6 @@
"confirm.delete_room": "Удалить комнату «{name}»?",
"confirm.delete_room_title": "Удалить комнату «{name}»?",
"confirm.delete_room_body": "Выберите, нужно ли оставить физические стены, принадлежащие только этой комнате. Общие с другой комнатой стены сохраняются всегда.",
"confirm.merge_rooms": "Слить комнаты «{a}» и «{b}»?",
"confirm.remove_marker": "Удалить «{name}» с плана? Устройство исчезнет полностью и перестанет участвовать в данных плана. Позже его можно будет добавить заново.",
"confirm.erase_decor": "Стереть объект «{kind}»? Действие можно отменить из истории редактора.",
"confirm.delete_space": "Удалить пространство «{title}» со всеми комнатами и разметкой?",
@@ -311,7 +305,6 @@
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
"toast.contour_min_edges": "Чтобы замкнуть контур, сначала нарисуйте минимум две грани",
"toast.contour_cannot_close": "Контур нельзя замкнуть: он вырожден или пересекает сам себя",
"toast.merge_not_adjacent": "Объединять можно только комнаты с общей стеной",
"toast.rooms_merged": "Комнаты объединены в «{name}»",
@@ -321,7 +314,6 @@
"merge.hint": "У объединённой комнаты одно имя и одна зона. Вторая зона освобождается — её устройства уйдут с плана, пока их не заберёт другая комната.",
"merge.keep": "Оставить",
"merge.no_area": "без зоны",
"room.split_header": "Новая комната после разделения",
"toast.room_saved": "Комната сохранена ({n}). Устройств добавлено: {added}. Обведите следующую или выйдите из разметки.",
"toast.room_saved_no_area": "Комната сохранена ({n}, без зоны). Обведите следующую или выйдите из разметки.",
"toast.marker_needs_server": "Редактирование устройств доступно после переноса конфига на сервер",
@@ -361,8 +353,6 @@
"editor.show_signal": "Показывать сигнал zigbee (LQI)",
"editor.language": "Язык интерфейса",
"editor.lang_auto": "Авто (профиль HA)",
"editor.lang_en": "English",
"editor.lang_ru": "Русский",
"title.icon_rules": "Правила иконок: какая MDI-иконка достаётся устройству по имени",
"rules.title": "Правила иконок",
"rules.hint": "Правила проверяются сверху вниз по строке «имя устройства + модель» (regex без учёта регистра); срабатывает первое совпадение. Если ничего не подошло — решает device class сущности, затем — иконка-заглушка.",
@@ -407,13 +397,7 @@
"space.custom_fill": "Цвет заливки",
"space.source_file": "У меня есть картинка плана",
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
"space.orientation": "Холст",
"orient.landscape": "Альбомный",
"orient.portrait": "Портретный",
"orient.square": "Квадрат",
"fill.temp": "По температуре",
"space.temp_min": "Комфорт от",
"space.temp_max": "до",
"tip.temp_avg": "средняя температура:",
"tip.hum_avg": "средняя влажность:",
"space_card.button": "Перейти к пространству",
@@ -457,8 +441,6 @@
"opening.lock_action": "Закрыть замок",
"opening.lock_pending": "Выполняется…",
"title.close_editor": "Закрыть редактор (вернуться к просмотру)",
"devbar.add": "Добавить",
"devbar.show_all": "Скрытые и деактивированные",
"devbar.rules": "Правила иконок",
"device_inbox.button": "Устройства",
"device_inbox.title": "Устройства на плане",
@@ -734,7 +716,6 @@
"marker.ha_registry_limited": "Полный реестр Home Assistant недоступен этому пользователю. Неподтверждённый объект временно нельзя показывать или использовать.",
"marker.delete_tip": "Полностью удалить устройство с плана и из всех расчётов. Позже его можно добавить заново.",
"tap.run": "Запустить автоматизацию/скрипт/сцену",
"tap.cover": "Открыть/закрыть (шторы/жалюзи)",
"marker.run_target_label": "Что запускать",
"marker.run_search_ph": "Поиск: автоматизация, скрипт или сцена…",
"marker.run_target_gone": "Цель {id} не найдена — выберите заново",
@@ -772,7 +753,6 @@
"run.scene": "сцена",
"confirm.tap_run": "Запустить «{name}»?",
"confirm.tap_toggle": "Переключить «{name}»?",
"confirm.tap_cover": "Открыть/закрыть «{name}»?",
"confirm.current_state": "Текущее состояние: {state}",
"confirm.expected_state": "После переключения: {state}",
"confirm.group_current": "включено {on} из {total}",
+10 -3
View File
@@ -5,11 +5,13 @@
* (no clicks/hover/tooltips/drag/more-info). A footer button opens
* the space in the full component via a deep-link (`#space=<id>`).
*/
import { LitElement, html, nothing, css, type TemplateResult, type PropertyValues } from 'lit';
import { LitElement, html, nothing, noChange, css, type TemplateResult, type PropertyValues } from 'lit';
import { cardStyles } from './styles';
import { buildSpaceDevices, renderSpaceStatic, spaceModels } from './space-render';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n';
import { LANGUAGE_RUNTIME } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
import { ContentSigner } from './signing';
import { normalizeDeviceDisplay, openingEntityReferences, referencedContentUrls } from './logic';
import { acquireHaRegistries, activeRegistryHass, haRegistrySnapshot } from './ha-binding-status';
@@ -758,8 +760,13 @@ class HouseplanSpaceCard extends LitElement {
.map((event) => ({ ...event, ...(event.stage ? { stage: [...event.stage] as [number, number] } : {}) }));
}
protected render(): TemplateResult | typeof nothing {
if (!this._config) return nothing;
protected render(): TemplateResult | typeof nothing | typeof noChange {
if (!this._config || !this.hass) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
const cfg = this._snap?.config;
if (!cfg) {
// still loading and no snapshot yet
+8 -1
View File
@@ -1,6 +1,8 @@
/** Config editor (Lovelace GUI) for houseplan-space-card. */
import { LitElement, html, nothing } from 'lit';
import { LitElement, html, nothing, noChange } from 'lit';
import { langOf, t, type Lang } from './i18n';
import { LANGUAGE_RUNTIME } from './i18n/registry';
import { languageLoadingTemplate, languageRenderGate } from './i18n/language-runtime';
class HouseplanSpaceCardEditor extends LitElement {
public hass?: any;
@@ -54,6 +56,11 @@ class HouseplanSpaceCardEditor extends LitElement {
protected render() {
if (!this.hass || !this._config) return nothing;
const localeGate = languageRenderGate(
this, LANGUAGE_RUNTIME, langOf(this.hass, this._config.language),
);
if (localeGate === 'cold') return languageLoadingTemplate();
if (localeGate === 'warm') return noChange;
this._loadSpaces();
const L = this._lang;
const labels: Record<string, string> = {