This commit is contained in:
Matysh
2026-08-02 12:54:36 +03:00
25 changed files with 1141 additions and 178 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.55.1"
VERSION = "1.55.2"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.55.1"
"version": "1.55.2"
}
@@ -173,6 +173,8 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
vol.Optional("show_borders"): bool,
vol.Optional("show_names"): bool,
vol.Optional("room_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
# per-space background around the plan; absent = inherit the global one
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "glow"]),
vol.Optional("temp_min"): vol.Coerce(float),
@@ -319,6 +321,8 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional("settings", default=dict): vol.Schema(
{
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
# background around the plan, all spaces (a space may override)
vol.Optional("bg_color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
vol.Optional("known_devices"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("new_device_ids"): vol.All([_TEXT], vol.Length(max=MAX_KNOWN_DEVICES)),
vol.Optional("fill_colors"): vol.Schema(
+4
View File
@@ -56,6 +56,10 @@ export async function launch(viewport = { width: 820, height: 760 }, scale = 1)
});
await page.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
// the stage height settles — real pointer interaction cannot hit a hidden
// plan, so every smoke starts where the user does: with the plan revealed.
await page.waitForFunction(() => window.__card._booting === false, { timeout: 9000 });
// hass flows continuously in production; the stub sets it once — nudge a rebuild
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
+144
View File
@@ -0,0 +1,144 @@
// New setting "background color around the plan": global (config.settings.bg_color,
// gear dialog) with a per-space override (space dialog, empty = inherit), applied
// to the stage in view and kiosk modes and to the static space-card. Editors keep
// their own canvas; the open dialogs preview the pending color live.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const stageBg = async () => {
c.requestUpdate();
await c.updateComplete;
return getComputedStyle(sr().querySelector('.stage')).backgroundColor;
};
const rgb = (hex) => `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
const themeBg = await stageBg(); // theme default from the stylesheet
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
// 1) global setting colors the stage
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#224466' };
out.globalApplies = (await stageBg()) === rgb('#224466');
// 2) per-space override wins
sp.settings = { ...(sp.settings || {}), bg_color: '#663311' };
out.spaceOverrides = (await stageBg()) === rgb('#663311');
// 3) clearing the space setting inherits the global again
delete sp.settings.bg_color;
out.clearInheritsGlobal = (await stageBg()) === rgb('#224466');
// 4) clearing the global returns the theme default
delete c._serverCfg.settings.bg_color;
out.clearRestoresTheme = (await stageBg()) === themeBg;
// 5) invalid stored value is ignored (renderer never trusts garbage)
c._serverCfg.settings.bg_color = 'url(javascript:alert(1))';
out.garbageIgnored = (await stageBg()) === themeBg;
delete c._serverCfg.settings.bg_color;
// 6) the settings dialog previews its pending color live + saves it
let saved = null;
const origWS = c.hass.callWS;
c.hass.callWS = async (m) => {
if (m.type === 'houseplan/config/set') { saved = m.config; return { rev: (c._cfgRev || 0) + 1 }; }
return origWS.call(c.hass, m);
};
c._openSettingsDialog();
await c.updateComplete;
out.dialogHasBgRow = !!sr().querySelector('.dialog .colorrow input[type=color]');
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
await c._saveSettingsDialog();
out.dialogSaves = saved?.settings?.bg_color === '#0a2a4a';
out.savedApplies = (await stageBg()) === rgb('#0a2a4a');
// 7) the space dialog previews and saves the override
c._openSpaceDialog('edit', c._space);
await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, bgColor: '#4a0a2a' };
out.spaceDialogPreviews = (await stageBg()) === rgb('#4a0a2a');
saved = null;
await c._saveSpaceDialog();
const savedSp = saved?.spaces?.find((s) => s.id === c._space);
out.spaceDialogSaves = savedSp?.settings?.bg_color === '#4a0a2a';
out.spaceSavedApplies = (await stageBg()) === rgb('#4a0a2a');
// ...and clearing it in the dialog inherits the global again
c._openSpaceDialog('edit', c._space);
await c.updateComplete;
out.spaceDialogPrefilled = c._spaceDialog.bgColor === '#4a0a2a';
c._spaceDialog = { ...c._spaceDialog, bgColor: null };
await c._saveSpaceDialog();
// over the wire `undefined` keys vanish — check the JSON form, like the backend sees it
const wire = JSON.parse(JSON.stringify(saved));
const savedSp2 = wire?.spaces?.find((s) => s.id === c._space);
out.spaceDialogClears = !('bg_color' in (savedSp2?.settings || {}));
out.backToGlobal = (await stageBg()) === rgb('#0a2a4a');
c.hass.callWS = origWS;
// 8) editors keep their own canvas (inline bg only in view/kiosk)
c._setMode('decor');
const editorBg = await stageBg();
out.editorUnpainted = editorBg !== rgb('#0a2a4a');
c._setMode('view');
out.viewPaintedAgain = (await stageBg()) === rgb('#0a2a4a');
// 9) kiosk instance respects the setting
const k = document.createElement('houseplan-card');
k.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
k.hass = c.hass;
k.style.cssText = 'position:fixed;left:0;top:0;width:860px;height:860px;z-index:99';
document.body.appendChild(k);
await new Promise((r) => setTimeout(r, 300));
// the kiosk instance re-reads the pristine demo config — inject the color
// into ITS copy; what matters here is the kiosk rendering path
k._serverCfg.settings = { ...(k._serverCfg.settings || {}), bg_color: '#0a2a4a' };
k.hass = { ...c.hass };
k.requestUpdate();
await k.updateComplete;
const kStage = (k.shadowRoot || k.renderRoot).querySelector('.stage');
out.kioskApplies = !!kStage && getComputedStyle(kStage).backgroundColor === rgb('#0a2a4a');
k.remove();
// 10) the static space-card paints the same background
await customElements.whenDefined('houseplan-space-card');
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
cfg.settings = { ...(cfg.settings || {}), bg_color: '#123456' };
const hass2 = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
return origWS.call(c.hass, m);
} };
const scHost = document.createElement('div');
document.body.appendChild(scHost);
const sc = document.createElement('houseplan-space-card');
sc.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
sc.hass = hass2;
scHost.appendChild(sc);
const t0 = Date.now();
while (!sc.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) await new Promise((r) => setTimeout(r, 80));
await sc.updateComplete;
const st = sc.renderRoot.querySelector('.hp-static-stage');
out.staticCardApplies = !!st && getComputedStyle(st).backgroundColor === rgb('#123456');
// ...and the space override beats the global there too
cfg.spaces.find((s) => s.id === c._space).settings = { bg_color: '#654321' };
sc.hass = { ...hass2 };
sc._cfg = null; // force a config re-read
await new Promise((r) => setTimeout(r, 200));
sc.requestUpdate(); await sc.updateComplete;
const st2 = sc.renderRoot.querySelector('.hp-static-stage');
out.staticCardSpaceWins = !!st2 && getComputedStyle(st2).backgroundColor === rgb('#654321');
scHost.remove();
// tidy: drop the color so later smokes on this page see the theme default
delete c._serverCfg.settings.bg_color;
const spx = c._serverCfg.spaces.find((s) => s.id === c._space);
if (spx?.settings) delete spx.settings.bg_color;
await stageBg();
return out;
});
checkAll(res);
await finish(browser, res);
+12
View File
@@ -25,10 +25,22 @@ const res = await page.evaluate(async () => {
out.rectRendered = !!sr().querySelector('.decorlayer rect.dshape');
// 3) линия и овал
c._decorDraft = { kind: 'line', a: [g * 2, g * 2], b: [g * 8, g * 2], pid: 1 };
await c.updateComplete;
// превью рисуемой линии уже с круглыми концами (зубцы на стыках)
const draftLine = sr().querySelector('.decorlayer line.ddraft');
out.draftLineRound = !!draftLine
&& draftLine.getAttribute('stroke-linecap') === 'round'
&& draftLine.getAttribute('stroke-linejoin') === 'round';
c._decorCommitDraft();
c._decorDraft = { kind: 'ellipse', a: [g * 30, g * 30], b: [g * 40, g * 36], pid: 1 };
c._decorCommitDraft(); await c.updateComplete;
out.threeShapes = c._decorList.length === 3;
// сохранённые линии рендерятся с круглыми концами: диаметр = толщине линии,
// стык двух линий под углом — без зубца (скриншот владельца)
await c.updateComplete;
const lines = [...sr().querySelectorAll('.decorlayer line.dshape')];
out.savedLineRound = lines.length > 0 && lines.every((l) =>
l.getAttribute('stroke-linecap') === 'round' && l.getAttribute('stroke-linejoin') === 'round');
// вырожденная фигура не сохраняется
c._decorDraft = { kind: 'line', a: [g, g], b: [g, g], pid: 1 };
c._decorCommitDraft();
+2 -2
View File
@@ -28,8 +28,8 @@ const res = await page.evaluate(async () => {
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"rows": 11,
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill"],
"rows": 12, // 10 цветов + радиус свечения + фон вокруг плана
"groups": ["Fill: lights", "Fill: temperature", "Fill: zigbee signal", "Light-sources fill", "Stage background"],
"saved": {"c": "#ff00ff", "a": 0.5},
"lqiBefore": 7,
"lqiAfter": 0,
+144
View File
@@ -0,0 +1,144 @@
// HP-1552: residual jump on first open in NORMAL (non-kiosk) mode. The stage is
// calc(100dvh - _hdrH) and _hdrH is measured from HA's chrome, which loads
// AFTER the card's first paint — so the plan painted, then jumped when the
// panels landed. The fix hides the plan behind a boot veil (pulsing house)
// until the height reads the same twice in a row (or ~600 ms). This smoke
// simulates the late HA panel: a spacer above the card grows 0 → 56 px at
// t=300 ms. Assertions: the veil is shown, and there is NOT A SINGLE frame
// where the plan is visible at a non-final stage height. Kiosk: no veil ever.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
// ---------- phase 1: normal mode, HA panel lands late ----------
const res = await page.evaluate(async () => {
const out = {};
const spacer = document.createElement('div');
spacer.style.cssText = 'height:0px';
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
wrap.append(spacer, c);
document.body.appendChild(wrap);
const frames = [];
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let grown = false;
await new Promise((done) => {
const sample = () => {
const t = performance.now() - t0;
if (!grown && t >= 300) {
grown = true; // the "HA panel" arrives: everything above the card grows
spacer.style.height = '56px';
window.dispatchEvent(new Event('resize'));
}
const stage = sr().querySelector('.stage');
if (stage) {
const veil = sr().querySelector('.bootveil');
const wrapEl = stage.querySelector(':scope > .zoomwrap');
const planHidden =
!wrapEl ||
getComputedStyle(wrapEl).visibility === 'hidden' ||
(veil && !veil.classList.contains('off') && getComputedStyle(veil).opacity !== '0');
frames.push({
t: Math.round(t),
planVisible: !planHidden,
veil: !!veil,
veilBg: veil ? getComputedStyle(veil).backgroundColor : null,
house: !!(veil && veil.querySelector('.boothouse path')),
stageH: stage.clientHeight,
});
} else {
frames.push(null);
}
if (t < 1400) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
const seen = frames.filter(Boolean);
const finalH = seen[seen.length - 1].stageH;
out.sampledFrames = seen.length > 40;
out.veilShownWhileSettling = seen.some((f) => f.veil && !f.planVisible && f.house);
out.veilIsDark = (() => {
const f = seen.find((x) => x.veilBg);
const m = f && f.veilBg.match(/(\d+),\s*(\d+),\s*(\d+)/);
return !!m && +m[1] + +m[2] + +m[3] < 300; // dark, not white/transparent
})();
// THE assertion: no frame may show the plan at a stale (pre-panel) height
const bad = seen.filter((f) => f.planVisible && Math.abs(f.stageH - finalH) > 2);
out.noVisibleFrameAtStaleHeight = bad.length === 0
? true
: `plan visible at h=${bad[0].stageH} (final ${finalH}) at t=${bad[0].t}ms — the jump is visible`;
const shown = seen.filter((f) => f.planVisible);
out.planShownAfterSettle = shown.length >= 5 && Math.abs(shown[0].stageH - finalH) <= 2;
out.veilGoneInTheEnd = !seen[seen.length - 1].veil;
out.heightReactedToPanel = finalH !== seen[0].stageH; // the simulated panel actually moved the stage
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
// ---------- phase 2: kiosk never shows the veil ----------
const kiosk = await page.evaluate(async () => {
const out = {};
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = window.__card.hass;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;height:700px;z-index:99';
document.body.appendChild(c);
const sr = () => c.shadowRoot || c.renderRoot;
let veilEver = false;
let planHiddenEver = false;
const t0 = performance.now();
await new Promise((done) => {
const sample = () => {
const stage = sr().querySelector('.stage');
if (stage) {
if (sr().querySelector('.bootveil')) veilEver = true;
const wrapEl = stage.querySelector(':scope > .zoomwrap');
if (wrapEl && getComputedStyle(wrapEl).visibility === 'hidden') planHiddenEver = true;
}
if (performance.now() - t0 < 900) requestAnimationFrame(sample);
else done();
};
requestAnimationFrame(sample);
});
out.kioskNoVeil = !veilEver;
out.kioskPlanNeverHidden = !planHiddenEver;
c.remove();
return out;
});
check('kioskNoVeil', kiosk.kioskNoVeil);
check('kioskPlanNeverHidden', kiosk.kioskPlanNeverHidden);
// ---------- phase 3: prefers-reduced-motion → the house does not pulse ----------
await page.emulateMedia({ reducedMotion: 'reduce' });
const reduced = await page.evaluate(async () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__card.hass;
c.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99';
document.body.appendChild(c);
const sr = () => c.shadowRoot || c.renderRoot;
const t0 = performance.now();
let anim = 'missed';
await new Promise((done) => {
const tick = () => {
const house = sr().querySelector('.bootveil .boothouse');
if (house) { anim = getComputedStyle(house).animationName; done(); return; }
if (performance.now() - t0 < 500) requestAnimationFrame(tick);
else done();
};
requestAnimationFrame(tick);
});
c.remove();
return { anim };
});
check('reducedMotionStaticHouse', reduced.anim, 'none');
await finish(browser, { ...res, ...kiosk, ...reduced });
+110
View File
@@ -0,0 +1,110 @@
// HP-1551: opening the card with a saved per-space zoom must NOT paint even a
// single frame at the default fit ("the plan flashes and re-scales"). Repro of
// the field report: LS holds the config cache (instant render) and a saved
// zoom of 1.8, the server config arrives ~350 ms later like a real HA
// websocket round-trip. Every rAF frame from the first possible moment is
// sampled: a frame where the stage is visible AND the viewBox is at the
// default scale is the flash.
import { launch, check, finish } from './serve.mjs';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
const ZOOM = 1.8;
// ---- phase 1: prime the config cache exactly the way production does ------
const { page, browser } = await launch();
await page.waitForFunction(() => !!localStorage.getItem('houseplan_card_cfg_v1'), { timeout: 9000 });
const cfgCache = await page.evaluate(() => localStorage.getItem('houseplan_card_cfg_v1'));
// default fit width at zoom 1 (clean LS): the yardstick for "default scale"
const defaultFitW = await page.evaluate(() => window.__card._view.w);
await page.close();
// ---- phase 2: cold open with a saved zoom and a slow server ----------------
const ctx = await browser.newContext({ viewport: { width: 820, height: 760 } });
await ctx.route('**/*', (r) => {
const u = new URL(r.request().url());
let p = decodeURIComponent(u.pathname);
if (p === '/') p = '/demo.html';
const f = ROOT + p;
existsSync(f)
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
: r.fulfill({ status: 404, body: 'nf' });
});
await ctx.addInitScript(([cache, zoom]) => {
localStorage.setItem('houseplan_card_cfg_v1', cache);
localStorage.setItem('houseplan_card_zoom_v1', JSON.stringify({ f1: zoom }));
localStorage.removeItem('houseplan_card_nav_v1');
window.__frames = [];
const origCreate = Document.prototype.createElement;
Document.prototype.createElement = function (tag, ...a) {
const el = origCreate.call(this, tag, ...a);
if (String(tag).toLowerCase() !== 'houseplan-card' || window.__hooked) return el;
window.__hooked = true;
// model the HA websocket latency: the config cache renders instantly,
// the live config lands 350 ms later
let proto = Object.getPrototypeOf(el), desc = null;
while (proto && !desc) { desc = Object.getOwnPropertyDescriptor(proto, 'hass'); proto = Object.getPrototypeOf(proto); }
if (desc?.set) {
Object.defineProperty(el, 'hass', {
configurable: true,
get() { return desc.get.call(this); },
set(v) {
if (v && typeof v.callWS === 'function' && !v.__slow) {
const orig = v.callWS.bind(v);
v.__slow = true;
v.callWS = (m) => (m && m.type === 'houseplan/config/get')
? new Promise((res) => setTimeout(() => res(orig(m)), 350))
: orig(m);
}
desc.set.call(this, v);
},
});
}
// rAF sampling from the first possible moment (before the first paint)
const sample = () => {
const sr = el.shadowRoot || el.renderRoot;
const stage = sr && sr.querySelector('.stage');
const svgEl = stage && stage.querySelector(':scope > .zoomwrap > svg');
if (stage && svgEl) {
const cs = getComputedStyle(stage);
const cw = getComputedStyle(svgEl.parentElement);
const cv = getComputedStyle(svgEl);
const r = stage.getBoundingClientRect();
const vb = (svgEl.getAttribute('viewBox') || '').split(/\s+/).map(Number);
const hidden = [cs, cw, cv].some((c) => c.opacity === '0' || c.visibility === 'hidden' || c.display === 'none');
window.__frames.push({ visible: !hidden && r.width > 1 && r.height > 1, vbW: vb[2] || 0, zoom: el._zoom });
} else {
window.__frames.push(null);
}
if (window.__frames.length < 75) requestAnimationFrame(sample);
else window.__framesDone = true;
};
requestAnimationFrame(sample);
return el;
};
}, [cfgCache, ZOOM]);
const p2 = await ctx.newPage();
p2.on('pageerror', (e) => console.log('EXC2', e.message));
await p2.goto('http://demo.local/demo.html', { waitUntil: 'domcontentloaded' });
await p2.waitForFunction(() => window.__framesDone === true, { timeout: 15000 });
const frames = await p2.evaluate(() => window.__frames);
const targetW = defaultFitW / ZOOM;
const seen = frames.filter(Boolean);
// the flash: stage visible while the viewBox is still at the default scale
// (clearly wider than the saved-zoom view; the default fit is 1.8x wider)
const flashFrames = seen.filter((f) => f.visible && f.vbW > targetW * 1.35);
const last = seen[seen.length - 1];
const goodFrames = seen.filter((f) => f.visible && Math.abs(f.vbW - targetW) < targetW * 0.02);
check('sampledFrames', seen.length > 30);
check('noDefaultScaleFlash', flashFrames.length === 0 ? true
: `flash: ${flashFrames.length} visible frame(s) at default scale, e.g. vbW=${flashFrames[0].vbW.toFixed(3)} vs target ${targetW.toFixed(3)}`);
check('settledAtSavedZoom', !!last && last.visible && Math.abs(last.zoom - ZOOM) < 0.01
&& Math.abs(last.vbW - targetW) < targetW * 0.02);
check('planActuallyShown', goodFrames.length >= 5); // the fix must not just hide the plan forever
await finish(browser);
File diff suppressed because one or more lines are too long
+125 -49
View File
File diff suppressed because one or more lines are too long
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## v1.55.2 — 2026-08-02
Patch release: a calmer first open — no zoom flash and no layout jumps
while Home Assistant is still settling — plus a new background color
setting and two bits of editor polish.
- **Opening a plan no longer flashes the default view before your saved
zoom** (HP-1551). The saved zoom used to be applied a beat after the
first render, so the card painted the default fit for a frame and then
snapped to your position. It is now applied synchronously, before the
first paint — the plan appears exactly where you left it.
- **A first-open veil hides the plan until the layout settles**
(HP-1552). While Home Assistant is still loading its side panels, the
stage height can change several times, and the plan visibly jumped
along with it. The card now waits under a dark veil with a pulsing
house outline until the height stops moving, then reveals the plan
already in place. Kiosk mode skips the veil entirely.
- **New setting: background color around the plan** (HP-1554). The area
around the plan can now be any color — set it in general settings for
the whole card, or override it per space. The backend validates the
value as a #rrggbb hex color.
- **Round caps and joins on background-editor lines** (HP-1553). Strokes
used to end and meet in square cuts; line ends and corners are rounded
now, so traced walls and outlines look clean.
- **The Resize tool's wall handles now look the part.** They are half
the previous size, show a wall-with-arrows glyph that rotates to
follow the wall, and use the grab cursor — while the hit area stays
exactly as large as before.
## v1.55.1 — 2026-08-01
Patch release: the four findings of the v1.55.0 audit, all in the new
+29
View File
@@ -6,6 +6,35 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.55.2 — 2026-08-02
Патч-релиз: спокойное первое открытие — без вспышки зума и прыжков
макета, пока Home Assistant догружается, — плюс новая настройка цвета
фона и две полировки редактора.
- **План больше не мигает видом по умолчанию перед сохранённым зумом**
(HP-1551). Сохранённый зум применялся на такт позже первого рендера:
карточка на кадр рисовала стандартную подгонку и лишь потом прыгала к
вашей позиции. Теперь он применяется синхронно, до первой отрисовки —
план появляется ровно там, где вы его оставили.
- **Вуаль первого открытия прячет план, пока макет не устаканится**
(HP-1552). Пока Home Assistant догружает свои панели, высота сцены
может меняться несколько раз, и план заметно прыгал вместе с ней.
Теперь карточка ждёт под тёмной вуалью с пульсирующим контуром домика,
пока высота не перестанет меняться, и показывает план уже на месте.
В режиме киоска вуаль не показывается вовсе.
- **Новая настройка: цвет фона вокруг плана** (HP-1554). Области вокруг
плана теперь можно задать любой цвет — в общих настройках для всей
карточки или отдельно для каждого пространства. Бэкенд проверяет
значение как hex-цвет #rrggbb.
- **Круглые концы и стыки линий в редакторе подложки** (HP-1553). Штрихи
обрывались и стыковались квадратными срезами; теперь концы линий и
углы скруглены — обведённые стены и контуры выглядят аккуратно.
- **Ручки стен в инструменте Resize теперь выглядят по делу.** Они вдвое
меньше прежних, несут значок «стенка со стрелочками», повёрнутый вдоль
своей стены, и показывают курсор grab — при этом зона клика осталась
прежней, большой.
## v1.55.1 — 2026-08-01
Патч-релиз: четыре находки аудита v1.55.0, все — в новом инструменте
+8 -3
View File
@@ -19,9 +19,14 @@ before — no handles, no new hit areas.
## Mechanism A — wall drag
- Every visible room shows a small handle at the midpoint of every
wall (handles for all rooms at once — owner's UX pick; they are
finger-sized but unobtrusive, radius derived from `view.w` like
`.vacfithandle`).
wall (handles for all rooms at once — owner's UX pick). The visible
glyph is a compact icon — a wall segment with two arrows pointing
perpendicular to it (the directions the wall drags), rotated to the
wall's orientation; accent ink over a `--hp-bg` halo. It is half the
size of the old circle, but the HIT area is an invisible circle of
the original finger-sized radius (derived from `view.w` like
`.vacfithandle`), so touch targets did not shrink. Cursor: `grab`,
`grabbing` while dragging.
- Dragging a handle moves the wall along its outward normal; **both
ends of the edge translate together** (the wall stays parallel to
itself; adjacent walls stretch/shrink). Works for any polygon
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State |
|---|---|
| Version | **v1.55.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Version | **v1.55.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.55.1** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.55.2** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.55.1",
"version": "1.55.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.55.1",
"version": "1.55.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.55.1",
"version": "1.55.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+158 -11
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,
@@ -29,6 +29,7 @@ import {
simplifyPoly, areaM2, formatArea, MIN_ROOM_CM, type EdgeDragPlan,
} from './resize';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
import {
Affine, applyAffine, solveAffine, affineResidual, readVacTelemetry, isVacSourceState,
autoCalibrate, pushTrailPoint, isVacMoving, vacTrailMode, vacMapIdWithFallback, VAC_TELEPORT_GAP_MS, VAC_STALE_MS,
@@ -46,7 +47,7 @@ import { cardStyles } from './styles';
import { fitInSquare, contentBounds, spaceModels } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.55.1';
const CARD_VERSION = '1.55.2';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -289,12 +290,23 @@ class HouseplanCard extends LitElement {
private _roHdr?: ResizeObserver;
private _onWinResize?: () => void;
private _hdrH = 118; // measured px above the stage (see the observer in updated())
/** HP-1552: first-open boot veil. In normal (non-kiosk) mode the stage is
* calc(100dvh - _hdrH), and _hdrH is measured from HA's chrome — which
* finishes loading AFTER the card's first paint, so every late panel
* nudged the height and the plan visibly jumped. Until the height reads
* the same twice in a row (or ~600 ms pass) the plan hides behind a
* pulsing-house veil. First open only; kiosk is 100dvh and never jumps. */
private _booting = true;
private _bootFading = false; // veil kept one beat for the opacity-out
private _bootTimer?: number;
private _bootLastH = -1;
private _bootStart = 0;
/** The accidental-tap guard: pending confirmation for a toggle/run tap. */
private _tapConfirm: { text: string; exec: () => void } | null = null;
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 +375,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;
@@ -439,6 +452,8 @@ class HouseplanCard extends LitElement {
static properties = {
_hdrH: { state: true },
_booting: { state: true },
_bootFading: { state: true },
_tapConfirm: { state: true },
hass: { attribute: false },
_config: { state: true },
@@ -514,6 +529,7 @@ class HouseplanCard extends LitElement {
this._signer.dispose();
clearTimeout(this._toastTimer);
clearTimeout(this._slideTimer);
clearTimeout(this._bootTimer);
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
window.removeEventListener('hashchange', this._onHashChange);
clearTimeout(this._holdTimer);
@@ -649,6 +665,7 @@ class HouseplanCard extends LitElement {
public setConfig(config: CardConfig): void {
this._config = { icon_size: 2.5, show_temperature: true, live_states: true, show_signal: true, ...config };
if (this._config.kiosk) { this._booting = false; this._bootFading = false; } // kiosk: 100dvh, nothing to settle
if (config.default_floor) this._space = config.default_floor;
try {
this._zoomBySpace = JSON.parse(localStorage.getItem(LS_ZOOM) || '{}') || {};
@@ -684,6 +701,13 @@ class HouseplanCard extends LitElement {
} catch {
/* ignore */
}
// HP-1551: the saved per-space zoom used to be applied only by
// _restoreZoom()'s rAF after the server round-trip, so the cached config
// painted its first frames at the default fit and the plan visibly
// jumped to the saved zoom. The zoom store is already in hand here —
// arm it BEFORE the first view computation, so the very first paint is
// already at the user's zoom.
if (this._mode === 'view' && !this._view) this._zoom = this._zoomBySpace[this._space] || 1;
}
/** Save a snapshot of the config+layout to localStorage for an instant start. */
@@ -883,6 +907,7 @@ class HouseplanCard extends LitElement {
this._roViewport = new ResizeObserver(() => this._refitView());
this._roViewport.observe(stage);
}
if (stage && this._booting && !this._bootTimer) this._bootWatch();
// The stage fills the rest of the viewport. What sits above it inside the
// CARD depends on the mode — the editor bars used to be billed against a
// hard-coded 118px, so entering an editor pushed the plan down by the
@@ -1575,6 +1600,35 @@ class HouseplanCard extends LitElement {
this._view = this._clampView({ x: ccx - w / 2, y: ccy - h / 2, w, h }, fit);
}
/**
* HP-1552: poll the stage height until it settles — two equal reads in a
* row, or a hard cap of ~600 ms (HA's panels normally land well inside it).
* Only then is the plan revealed, already refit to the final geometry.
*/
private _bootWatch(): void {
this._bootStart = Date.now();
this._bootLastH = -1; // the first read only arms the comparison
const tick = () => {
if (!this._booting) return;
const h = this._stageEl ? this._stageEl.clientHeight : 0;
if ((h > 0 && h === this._bootLastH) || Date.now() - this._bootStart >= 600) {
this._bootSettled();
return;
}
this._bootLastH = h;
this._bootTimer = window.setTimeout(tick, 200);
};
this._bootTimer = window.setTimeout(tick, 200);
}
private _bootSettled(): void {
if (!this._booting) return;
this._refitView(); // reveal in the final geometry, never mid-jump
this._booting = false;
this._bootFading = true; // one soft opacity-out, then out of the DOM
this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220);
}
/** Recompute the view for a new scene size, preserving zoom and center. */
private _refitView(): void {
if (!this._stageEl) return;
@@ -1643,6 +1697,20 @@ class HouseplanCard extends LitElement {
private _restoreZoom(): void {
const z = this._zoomBySpace[this._space] || 1;
this._zoom = z;
const stage = this._stageEl;
if (stage && stage.clientHeight) {
// HP-1551: the stage is already measured — apply the view NOW, before
// the next paint. The unconditional rAF here used to let one frame
// (or a whole server round-trip worth of frames, via the updated()
// refit running with the stale zoom before this method was called at
// all) paint at the default fit — the visible "flash" on opening.
const vb = this._baseVb();
this._applyView(z, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
this.requestUpdate();
return;
}
// stage not measurable yet: let updated() fit it with the (already
// correct) _zoom on the first layout, then center on the plan
this._view = null;
requestAnimationFrame(() => {
if (!this._stageEl) return;
@@ -2470,19 +2538,32 @@ class HouseplanCard extends LitElement {
/** Handles of the resize tool: wall midpoints + the scale frame of the selected room. */
private _renderResizeLayer(view: { x: number; y: number; w: number; h: number }): TemplateResult {
const hr = Math.max(view.w * 0.013, 5); // finger-sized on touch, like .vacfithandle
const hr = Math.max(view.w * 0.013, 5); // finger-sized HIT radius on touch, like .vacfithandle
// Wall-handle glyph: half the old circle — a wall segment with two arrows
// pointing perpendicular to it (the directions the wall can be dragged).
// Drawn in local coords (wall along X), rotated per edge at render time.
// The invisible circle above keeps the full finger-sized hit area and the
// HP-1550-04 hit-test priority over openings.
const s = hr / 2, f = (v: number) => v.toFixed(1);
const iconD =
`M ${f(-0.7 * s)} 0 H ${f(0.7 * s)}` +
` M 0 ${f(-0.22 * s)} V ${f(-s)} M ${f(-0.32 * s)} ${f(-0.6 * s)} L 0 ${f(-s)} L ${f(0.32 * s)} ${f(-0.6 * s)}` +
` M 0 ${f(0.22 * s)} V ${f(s)} M ${f(-0.32 * s)} ${f(0.6 * s)} L 0 ${f(s)} L ${f(0.32 * s)} ${f(0.6 * s)}`;
const parts: TemplateResult[] = [];
const rooms = this._rszRooms();
for (const r of rooms) {
for (let i = 0; i < r.poly.length; i++) {
const a = r.poly[i], b = r.poly[(i + 1) % r.poly.length];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < this._gridPitch) continue;
parts.push(svg`<circle class="rszhandle" cx="${((a[0] + b[0]) / 2).toFixed(1)}" cy="${((a[1] + b[1]) / 2).toFixed(1)}" r="${hr.toFixed(1)}"
const mx = f((a[0] + b[0]) / 2), my = f((a[1] + b[1]) / 2);
const ang = f(Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI);
parts.push(svg`<circle class="rszhandle" cx="${mx}" cy="${my}" r="${f(hr)}"
@pointerdown=${(e: PointerEvent) => this._rszEdgeDown(e, r.id, i)}
@pointermove=${(e: PointerEvent) => this._rszMove(e)}
@pointerup=${(e: PointerEvent) => this._rszUp(e)}
@pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)}
@lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)}></circle>`);
parts.push(svg`<g class="rszicon" transform="translate(${mx} ${my}) rotate(${ang})"><path class="rszhalo" d="${iconD}"></path><path class="rszink" d="${iconD}"></path></g>`);
}
}
const sel = this._rszSel ? rooms.find((r) => r.id === this._rszSel) : null;
@@ -2668,8 +2749,10 @@ class HouseplanCard extends LitElement {
const down = (e: PointerEvent) => this._decorShapeDown(e, sh);
const dbl = () => this._decorShapeDbl(sh);
if (sh.kind === 'line')
// round caps: line ends read as circles of the stroke width, so two
// lines meeting at an angle join without the notch (owner's screenshot)
return svg`<line class="${cls}" x1="${sh.x1 * W}" y1="${sh.y1 * H}" x2="${sh.x2 * W}" y2="${sh.y2 * H}"
stroke="${sh.color}" stroke-width="${sh.width}" @pointerdown=${down}></line>`;
stroke="${sh.color}" stroke-width="${sh.width}" stroke-linecap="round" stroke-linejoin="round" @pointerdown=${down}></line>`;
if (sh.kind === 'rect')
return svg`<rect class="${cls}" x="${sh.x * W}" y="${sh.y * H}" width="${sh.w * W}" height="${sh.h * H}"
stroke="${sh.color}" stroke-width="${sh.width}"
@@ -2690,7 +2773,7 @@ class HouseplanCard extends LitElement {
const st = this._decorStyle;
if (d.kind === 'line')
draft = svg`<line class="ddraft" x1="${d.a[0]}" y1="${d.a[1]}" x2="${d.b[0]}" y2="${d.b[1]}"
stroke="${st.color}" stroke-width="${st.width}"></line>`;
stroke="${st.color}" stroke-width="${st.width}" stroke-linecap="round" stroke-linejoin="round"></line>`;
else {
const x = Math.min(d.a[0], d.b[0]), y = Math.min(d.a[1], d.b[1]);
const w = Math.abs(d.b[0] - d.a[0]), h = Math.abs(d.b[1] - d.a[1]);
@@ -3753,6 +3836,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,
@@ -3767,6 +3851,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,
@@ -3994,6 +4079,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,
@@ -4100,6 +4186,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,
@@ -4155,6 +4242,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 => {
@@ -4164,7 +4276,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 {
@@ -4185,6 +4302,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;
@@ -4337,10 +4456,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>
@@ -4534,6 +4664,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>
@@ -4604,8 +4737,8 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</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)`}"
<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}${this._booting ? ' hpboot' : ''}"
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); }}
@@ -4741,6 +4874,11 @@ class HouseplanCard extends LitElement {
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
: nothing}
${this._booting || this._bootFading
? html`<div class="bootveil ${this._booting ? '' : 'off'}" aria-hidden="true">
<svg class="boothouse" viewBox="0 0 24 24"><path d="${mdiHomeCityOutline}"></path></svg>
</div>`
: nothing}
</div>
${this._roomDialog ? this._renderRoomDialog() : nothing}
@@ -6704,6 +6842,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" />`
+63 -4
View File
@@ -151,6 +151,44 @@ export const cardStyles = css`
position: absolute;
inset: 0;
}
/* HP-1552: first-open boot veil — the plan hides until the stage height settles */
.stage.hpboot .zoomwrap,
.stage.hpboot .zoombadge {
visibility: hidden;
}
.bootveil {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: var(--hp-bg, #16212e);
opacity: 1;
transition: opacity 0.15s ease;
pointer-events: none;
}
.bootveil.off {
opacity: 0;
}
.bootveil .boothouse {
position: static; /* .stage svg pins itself to inset:0 — not this one */
width: 56px;
height: 56px;
fill: var(--hp-accent);
opacity: 0.85;
animation: hp-boot-pulse 1.3s ease-in-out infinite;
}
@keyframes hp-boot-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.94); }
50% { opacity: 0.9; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.bootveil .boothouse {
animation: none;
opacity: 0.7;
}
}
.zoomctl {
display: inline-flex;
gap: 2px;
@@ -1412,16 +1450,37 @@ export const cardStyles = css`
user-select: none;
}
/* room resize tool (docs/RESIZE.md) */
/* wall handle: invisible finger-sized hit circle (HP-1550-04 hit priority
kept), the visible glyph lives in the sibling .rszicon */
.rszhandle {
fill: transparent;
stroke: none;
pointer-events: all;
cursor: grab;
touch-action: none;
}
.rszhandle:active { cursor: grabbing; }
/* wall-with-arrows glyph: accent ink over a bg halo, readable on any plan */
.rszicon { pointer-events: none; }
.rszicon path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.rszhalo { stroke: var(--hp-bg); stroke-width: 6; }
.rszink { stroke: var(--hp-accent); stroke-width: 2; }
.rszhandle:hover + .rszicon .rszink { stroke-width: 3; }
/* corner (scale-frame) handles keep the classic filled circle */
.rszcorner {
fill: var(--hp-bg);
stroke: var(--hp-accent);
stroke-width: 2;
vector-effect: non-scaling-stroke;
cursor: move;
touch-action: none;
cursor: nwse-resize;
}
.rszhandle:hover { fill: var(--hp-accent); }
.rszcorner { cursor: nwse-resize; }
.rszcorner:hover { fill: var(--hp-accent); }
.rszcorner:active { cursor: nwse-resize; }
.rszframe {
fill: none;
stroke: var(--hp-accent);
+18
View File
@@ -164,6 +164,24 @@ def test_space_display_settings():
v.SPACE_SCHEMA(bad_mode)
def test_bg_color_setting():
"""Background-around-the-plan color: strict #rrggbb, globally and per space."""
ok_space = {
"id": "f1", "title": "F", "view_box": [0, 0, 1, 1], "rooms": [],
"settings": {"bg_color": "#102030"},
}
v.SPACE_SCHEMA(ok_space)
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#A1b2C3"}})
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA(dict(ok_space, settings={"bg_color": "red"}))
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "url(javascript:x)"}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#12345"}})
with pytest.raises(vol.Invalid):
v.CONFIG_SCHEMA({"spaces": [], "settings": {"bg_color": "#1234567"}})
def test_space_temp_bounds():
"""Temperature comfort bounds validate as floats; temp fill mode accepted."""
ok = {