feat: background color around the plan — global + per-space (HP-1554)

New setting 'background around the plan' (#rrggbb):
- global: config.settings.bg_color, edited in the gear dialog with a live
  preview and a 'theme default' reset (empty = keep the stylesheet default);
- per-space override in the space dialog next to the room colors, empty =
  inherit the general setting (the show_lqi/fill_mode pattern);
- applied to the stage in view and kiosk modes (editors keep their own
  canvas) and to the static houseplan-space-card;
- backend validates both keys with the same strict #rrggbb match as
  room_color; garbage strings are rejected (test_bg_color_setting).
smoke_bg_color covers apply/override/inherit/reset, dialog previews, the
wire format of the cleared override, kiosk and the static card.
This commit is contained in:
Codex
2026-08-02 10:48:51 +03:00
parent 941d2709fd
commit 6414873fb0
12 changed files with 426 additions and 128 deletions
+65 -5
View File
@@ -19,7 +19,7 @@ import {
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, runServiceFor, RUN_TARGET_DOMAINS,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
referencedContentUrls,
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
@@ -294,7 +294,7 @@ class HouseplanCard extends LitElement {
private _onboardingShown = false; // the auto space dialog is shown once per session
private _rulesDialog: { rules: IconRule[]; test: string; busy: boolean } | null = null;
private _settingsDialog: { colors: FillColors; glowRadius: number; busy: boolean } | null = null;
private _settingsDialog: { colors: FillColors; glowRadius: number; bgColor: string | null; busy: boolean } | null = null;
private _importDialog: { floors: (FloorInfo & { checked: boolean })[] } | null = null;
private _importQueue: string[] = []; // floor titles still to create
private _importTotal = 0;
@@ -363,6 +363,7 @@ class HouseplanCard extends LitElement {
showNames: boolean;
roomColor: string;
roomOpacity: number; // 0..1
bgColor: string | null; // background around the plan; null = inherit general
fillMode: 'none' | 'lqi' | 'light' | 'temp' | 'glow';
tempMin: number;
tempMax: number;
@@ -3776,6 +3777,7 @@ class HouseplanCard extends LitElement {
source: sp.plan_url ? 'file' : 'draw',
showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
bgColor: disp.bgColor,
tempMin: disp.tempMin, tempMax: disp.tempMax,
showLqi: disp.showLqi ?? this._config?.show_signal ?? true,
cardFontScale: disp.cardFontScale,
@@ -3790,6 +3792,7 @@ class HouseplanCard extends LitElement {
source: 'file',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
bgColor: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
@@ -4017,6 +4020,7 @@ class HouseplanCard extends LitElement {
show_names: draw && d.mode === 'create' ? true : d.showNames,
room_color: d.roomColor,
room_opacity: d.roomOpacity,
bg_color: d.bgColor || undefined, // empty = inherit the general setting
fill_mode: d.fillMode,
temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN,
temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX,
@@ -4123,6 +4127,7 @@ class HouseplanCard extends LitElement {
source: 'file',
showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'glow',
bgColor: null,
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
@@ -4178,6 +4183,31 @@ class HouseplanCard extends LitElement {
</div>`;
}
/**
* Effective stage background: per-space override → global setting → '' (the
* theme default from the stylesheet). An open dialog previews its pending
* value, so the color can be picked against the live plan.
*/
private _stageBg(disp: SpaceDisplay): string {
const gd = this._settingsDialog;
const sd = this._spaceDialog;
const globalBg = gd ? gd.bgColor || '' : stageBgOf(this._settings, { bgColor: null });
const spaceBg = sd && sd.mode === 'edit' && sd.spaceId === this._space
? sd.bgColor || ''
: disp.bgColor || '';
return spaceBg || globalBg;
}
/** Current computed stage background as #rrggbb — the color input's default. */
private _stageBgHex(): string {
const st = this._stageEl;
if (st) {
const m = getComputedStyle(st).backgroundColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (m) return '#' + m.slice(1, 4).map((n) => (+n).toString(16).padStart(2, '0')).join('');
}
return '#111111';
}
// ================= GENERAL SETTINGS =================
private _openSettingsDialog = (): void => {
@@ -4187,7 +4217,12 @@ class HouseplanCard extends LitElement {
const glowRadius = this._imperial
? Math.round((cm / 30.48) * 10) / 10
: Math.round(cm) / 100;
this._settingsDialog = { colors: JSON.parse(JSON.stringify(this._fillColors)), glowRadius, busy: false };
this._settingsDialog = {
colors: JSON.parse(JSON.stringify(this._fillColors)),
glowRadius,
bgColor: stageBgOf(this._settings, { bgColor: null }) || null,
busy: false,
};
};
private _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void {
@@ -4208,6 +4243,8 @@ class HouseplanCard extends LitElement {
const cm = this._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100;
if (Number.isFinite(cm) && cm > 0 && Math.round(cm) !== 300) settings.glow_radius_cm = Math.round(cm);
else delete settings.glow_radius_cm;
if (d.bgColor) settings.bg_color = d.bgColor;
else delete settings.bg_color;
this._serverCfg = { ...cfg, settings };
await this._saveConfigNow();
this._settingsDialog = null;
@@ -4360,10 +4397,21 @@ class HouseplanCard extends LitElement {
}} />
<span class="opl">${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}</span>
</div>
<label class="dispsection">${this._t('gs.bg_group')}</label>
<div class="colorrow gsrow">
<span class="gsl">${this._t('gs.bg_color')}</span>
<input type="color" .value=${this._settingsDialog!.bgColor || this._stageBgHex()}
@input=${(e: Event) =>
(this._settingsDialog = { ...this._settingsDialog!, bgColor: (e.target as HTMLInputElement).value })} />
${this._settingsDialog!.bgColor
? html`<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, bgColor: null })}>${this._t('gs.bg_default')}</button>`
: html`<span class="opl">${this._t('gs.bg_theme')}</span>`}
</div>
</div>
<div class="row">
<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3 })}>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3, bgColor: null })}>
${this._t('gs.reset')}
</button>
<span class="spacer"></span>
@@ -4557,6 +4605,9 @@ class HouseplanCard extends LitElement {
const cfgSize = this._config.icon_size ?? 2.5;
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
const view = this._viewOr(vb);
// Background around the plan (view/kiosk; editors keep their own canvas).
// Both settings dialogs preview their pending value live.
const stageBg = this._editing ? '' : this._stageBg(disp);
return html`
<ha-card>
@@ -4628,7 +4679,7 @@ class HouseplanCard extends LitElement {
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
@@ -6727,6 +6778,15 @@ class HouseplanCard extends LitElement {
@input=${(e: Event) => (this._spaceDialog = { ...d, roomOpacity: Number((e.target as HTMLInputElement).value) / 100 })} />
<span class="opv">${Math.round(d.roomOpacity * 100)}%</span>
</div>
<label>${this._t('space.bg_color')}</label>
<div class="colorrow">
<input type="color" .value=${d.bgColor || stageBgOf(this._settings, { bgColor: null }) || this._stageBgHex()}
@input=${(e: Event) => (this._spaceDialog = { ...d, bgColor: (e.target as HTMLInputElement).value })} />
${d.bgColor
? html`<button class="btn ghost" @click=${() => (this._spaceDialog = { ...d, bgColor: null })}>
${this._t('space.bg_inherit')}</button>`
: html`<span class="opl">${this._t('space.bg_inherited')}</span>`}
</div>
<label>${this._t('space.fill_label')}</label>
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
([v, k]) => html`<label class="srcrow">
+8 -1
View File
@@ -379,5 +379,12 @@
"vac.fit_mirror": "Mirror",
"vac.trail_never": "Never",
"vac.trail_cleaning": "While cleaning",
"vac.trail_always": "Always"
"vac.trail_always": "Always",
"gs.bg_group": "Stage background",
"gs.bg_color": "Background around the plan",
"gs.bg_default": "Theme default",
"gs.bg_theme": "theme default",
"space.bg_color": "Background around the plan",
"space.bg_inherit": "Inherit general",
"space.bg_inherited": "inherits general settings"
}
+8 -1
View File
@@ -379,5 +379,12 @@
"vac.fit_mirror": "Отразить",
"vac.trail_never": "Не показывать никогда",
"vac.trail_cleaning": "Во время уборки",
"vac.trail_always": "Показывать всегда"
"vac.trail_always": "Показывать всегда",
"gs.bg_group": "Фон сцены",
"gs.bg_color": "Цвет фона вокруг плана",
"gs.bg_default": "Как в теме",
"gs.bg_theme": "по умолчанию — из темы",
"space.bg_color": "Цвет фона вокруг плана",
"space.bg_inherit": "Наследовать общий",
"space.bg_inherited": "наследуется из общих настроек"
}
+14
View File
@@ -739,6 +739,8 @@ export interface SpaceDisplay {
labelHum: boolean;
labelLqi: boolean;
labelLight: boolean;
/** Background around the plan; null = inherit the global setting / theme. */
bgColor: string | null;
}
export const DEFAULT_ROOM_COLOR = '#3ea6ff';
@@ -766,9 +768,21 @@ export function spaceDisplayOf(spaceCfg: any): SpaceDisplay {
labelHum: s.label_hum === true,
labelLqi: s.label_lqi === true,
labelLight: s.label_light === true,
bgColor: typeof s.bg_color === 'string' && /^#[0-9a-f]{6}$/i.test(s.bg_color) ? s.bg_color : null,
};
}
/**
* Effective background color around the plan: the per-space override wins,
* then the global config.settings.bg_color, then '' — meaning "keep the
* theme default" (the stage's stylesheet background stays untouched).
*/
export function stageBgOf(settings: any, disp: { bgColor: string | null }): string {
if (disp.bgColor) return disp.bgColor;
const g = settings?.bg_color;
return typeof g === 'string' && /^#[0-9a-f]{6}$/i.test(g) ? g : '';
}
// ---------------- global fill colors ----------------
export interface FillColorEntry {
+4 -2
View File
@@ -8,7 +8,7 @@
*/
import { html, svg, nothing, type TemplateResult } from 'lit';
import { buildDevices, areaLqi, areaLights, areaTemp } from './devices';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf } from './logic';
import { spaceDisplayOf, roomFillStyle, fillColorsOf, roomFillModeOf, stageBgOf } from './logic';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import type { ServerConfig } from './types';
@@ -146,9 +146,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: [];
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
// the static card paints the same background around the plan as the full one
const stageBg = stageBgOf(o.cfg?.settings, disp);
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`