mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
v1.55.2
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
@@ -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
Vendored
+125
-49
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "наследуется из общих настроек"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user