Compare commits

..
Author SHA1 Message Date
Matysh 0ee80a6a52 v1.59.0-beta.2: live text labels, text block frame, warm-memo owners
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m29s
Validate / backend (push) Failing after 8m30s
Validate / smoke (push) Failing after 13m24s
2026-08-04 23:44:59 +03:00
Matysh 1397a71f84 v1.59.0-beta.1: warm remount keeps your view and dialogs, sun ray rim
Validate / smoke (push) Failing after 22m41s
Validate / frontend (push) Successful in 2m47s
Validate / backend (push) Failing after 8m13s
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
2026-08-04 18:20:47 +03:00
36 changed files with 4500 additions and 956 deletions
-1
View File
@@ -4,4 +4,3 @@ test-build/
*.log
__pycache__/
.pytest_cache/
.venv-backend/
-19
View File
@@ -1,19 +0,0 @@
# AGENTS.md
House Plan is one HACS package with two parts plus a demo harness:
- **Lovelace card** (`src/`, TypeScript + Lit) — the primary product, bundled to `dist/houseplan-card.js`.
- **Storage integration** (`custom_components/houseplan/`, Python) — the Home Assistant backend.
- **Demo harness** (`demo/`) — a self-contained Playwright page (`demo/srv/demo.html`) that renders the card against a fake `hass`, used for screenshots and the `smoke_*.mjs` end-to-end suite.
Standard commands live in `package.json` scripts, `CONTRIBUTING.md`, and `docs/DEVELOPMENT.md`. Read `docs/ARCHITECTURE.md` and `docs/STATUS.md` before non-trivial changes.
## Cursor Cloud specific instructions
The startup update script already runs `npm ci`, provisions a Python 3.13 backend venv at `.venv-backend`, and installs Playwright Chromium. You do not need to reinstall dependencies.
- **Frontend** (from repo root): `npm run typecheck`, `npm test` (node:test, ~270 tests), `npm run build`. After building, keep the integration copy in sync — `cp dist/houseplan-card.js custom_components/houseplan/frontend/`. CI enforces `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` byte-for-byte.
- **Backend HA-harness tests need Python 3.13, not the system 3.12.** Run them with the venv: `.venv-backend/bin/python -m pytest tests_backend/ -q` (126 tests). Running `python3 -m pytest tests_backend` on the system 3.12 silently **skips** the `test_ha_*.py` harness tests (`conftest.py` ignores them when `homeassistant` is not importable) and runs only the ~83 pure tests.
- **Running the app / smoke suite**: build a fresh bundle and copy it into the demo assets first — `npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — then run `node demo/smoke_*.mjs`. The committed `demo/srv/assets/houseplan-card.js` is a stale snapshot; testing it reports green about code that no longer exists. No real Home Assistant server is required: `demo/srv/demo.html` stubs `hass`, registries and `callService`.
- **Demo harness render quirk**: the fake `hass` in `demo.html` is set once, so opening the page directly in a browser renders the floor plan but **device icons only appear after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The smoke launcher `demo/serve.mjs` already does this nudge; a plain browser session does not. This is a harness limitation, not a card bug.
- **Known environment-sensitive smoke**: `demo/smoke_opening_measure.mjs` fails two sub-checks (`place_dialog_x_magnetised`, `place_committed_x_center`) under the pinned Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It reproduces against the pristine committed bundle, so treat it as pre-existing/pixel-precision, not a regression you introduced.
+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.58.0"
VERSION = "1.59.0-beta.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.58.0"
"version": "1.59.0-beta.2"
}
+35 -2
View File
@@ -220,6 +220,22 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
# Live text on a decor label (docs/LIVE-TEXT.md). An entity id is
# `<domain>.<object_id>`; HA itself allows only lowercase letters, digits and
# underscores in both halves. The bound is a sanity limit, not a policy.
MAX_ENTITY_ID = 255
_ENTITY_ID = vol.All(str, vol.Length(min=3, max=MAX_ENTITY_ID),
vol.Match(r"^[a-z0-9_]+\.[a-z0-9_]+$"))
# A caption is a caption: the template is bounded, the attribute name is a flat
# name (no dots needed), and a unit is a couple of characters, not a sentence.
MAX_DECOR_TEXT = 200
MAX_DECOR_ATTR = 64
MAX_DECOR_UNIT = 16
# The text block is scaled by dragging its corners; the range is what a human
# could mean on a 1000-unit canvas, the rest is garbage insurance.
DECOR_TEXT_SCALE_MIN = 0.15
DECOR_TEXT_SCALE_MAX = 20.0
_DECOR_COMMON = {
vol.Required("id"): str,
vol.Optional("color"): vol.Match(r"^#[0-9a-fA-F]{6}$"),
@@ -240,8 +256,25 @@ DECOR_SCHEMA = vol.Any(
extra=vol.ALLOW_EXTRA),
vol.Schema({**_DECOR_COMMON, vol.Required("kind"): "text",
vol.Required("x"): _NORM, vol.Required("y"): _NORM,
vol.Required("text"): vol.All(str, vol.Length(min=1, max=200)),
vol.Optional("size"): vol.In(["s", "m", "l"])},
# the template: newlines are the user's own line breaks and are
# kept verbatim (docs/LIVE-TEXT.md); the label never wraps itself
vol.Required("text"): vol.All(str, vol.Length(min=1, max=MAX_DECOR_TEXT)),
# legacy font size ('s'|'m'|'l'). The dialog no longer offers it
# — the block is scaled by its corner handles — but a plan
# written before that keeps it, and it is read as the scale it
# used to render at. Kept in the schema so it stays BOUNDED.
vol.Optional("size"): vol.In(["s", "m", "l"]),
# size and rotation of the block (docs/LIVE-TEXT.md §block).
# Both optional: a shape without them is the old one exactly.
vol.Optional("scale"): vol.All(
_finite, vol.Range(min=DECOR_TEXT_SCALE_MIN, max=DECOR_TEXT_SCALE_MAX)),
vol.Optional("angle"): vol.All(_finite, vol.Range(min=-360.0, max=360.0)),
# live text: the label may show one entity's value. All three
# optional, so every existing plan validates unchanged and no
# migration runs.
vol.Optional("entity"): vol.Any(None, _ENTITY_ID),
vol.Optional("attr"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_ATTR))),
vol.Optional("unit"): vol.Any(None, vol.All(str, vol.Length(max=MAX_DECOR_UNIT)))},
extra=vol.ALLOW_EXTRA),
)
+64
View File
@@ -0,0 +1,64 @@
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
// the value can be picked by looking rather than by arguing. The alphas other
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
// DOM, which is why a rebuild is not needed to compare them.
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
// `0` is the "before" frame: the wedge alone, with no rim at all.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
const setup = async (scene) => {
await page.evaluate(async (sc) => {
const c = window.__card;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain paper, nothing to hide behind
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
c._cfgEpoch++;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 250, elevation: 35 } } } };
c.requestUpdate();
await c.updateComplete;
}, scene);
await page.waitForTimeout(300);
};
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
// scale relative to the current values would collapse to zero the moment the
// 0 frame is taken and never come back.
const setAlpha = async (peak) => {
await page.evaluate((a) => {
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
const stops = [...g.querySelectorAll('stop')];
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
const p0 = g.__base[0] || 1;
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
}
}, peak);
await page.waitForTimeout(120);
};
for (const scene of ['light', 'dark']) {
await setup(scene);
for (const a of ALPHAS) {
await setAlpha(a);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
}
}
await browser.close();
console.log('shots written to ' + outDir);
+8 -3
View File
@@ -86,8 +86,10 @@ const res = await page.evaluate(async () => {
c._decorTool = 'select';
await c.updateComplete;
out.stillThreeShapes = c._decorList.length === 3;
// 4) надпись через диалог
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', size: 'l', color: '#0000ff' };
// 4) надпись через диалог. Поля `size` в диалоге больше нет — размер задаётся
// углами блока (см. smoke_decor_text); сохранённый старый `size` по-прежнему
// читается как начальный масштаб.
c._decorTextDialog = { x: 0.5, y: 0.5, text: 'Сауна', color: '#0000ff' };
c._decorSaveText(); await c.updateComplete;
out.textSaved = c._decorList.some((x) => x.kind === 'text' && x.text === 'Сауна');
out.textRendered = [...sr().querySelectorAll('.decorlayer text')].some((t) => t.textContent.includes('Сауна'));
@@ -151,7 +153,10 @@ const res = await page.evaluate(async () => {
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
c._decorDraft = null;
}
// — «текст» по фигуре открывает диалог новой надписи, а не выделяет
// — «текст» по ЛИНИИ открывает диалог новой надписи, а не выделяет её.
// Инертность нетекстовых фигур под рисующими инструментами остаётся в силе;
// единственное исключение — надпись под инструментом «текст», она открывает
// СВОЮ форму (владелец 2026-08-04, пинится в smoke_decor_text).
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
press(probe(), g * 12, g * 30, 43);
await c.updateComplete;
+197
View File
@@ -0,0 +1,197 @@
// Текстовый блок декора (правки владельца 2026-08-04):
// 1) выбора размера шрифта БОЛЬШЕ НЕТ — размер задаётся углами блока, а
// сохранённый старый `size` читается как начальный масштаб;
// 2) многострочный текст: перевод строки сохраняется и рисуется, блок
// выравнивается по центру;
// 3) у выделенного блока есть угловые ручки (масштаб) и ручка поворота
// (шаг 5°, Shift — мимо шага);
// 4) при инструменте «текст» клик по УЖЕ РАЗМЕЩЁННОЙ надписи открывает её
// форму, а не создаёт новую; по пустому месту и по НЕтекстовой фигуре —
// создаёт новую (нетекстовые фигуры остаются инертными, см. smoke_decor).
// ПАДАЕТ на сборке до этих правок: в диалоге ещё радиокнопки размера, перевод
// строки схлопывается в одну строку, ручек нет, а «текст» по надписи создаёт
// вторую надпись поверх первой.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// null-safe: на сборке ДО этих правок у надписи нет ни data-id, ни tspan —
// смок должен показать список провалов, а не упасть с исключением
const el = (id) => sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
const attr = (id, name) => { const e = el(id); return e ? e.getAttribute(name) : null; };
const tspans = (id) => { const e = el(id); return e ? [...e.querySelectorAll('tspan')] : []; };
const stageEl = () => sr().querySelector('.stage');
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height };
};
const ev = (type, target, x, y, extra = {}) => {
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
cancelable: true, pointerId: 7, clientX, clientY, button: 0, isPrimary: true, ...extra }));
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
c._curSpaceCfg.decor = [];
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
// ================= 1. размера шрифта в диалоге больше нет ================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
await c.updateComplete;
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
out.textareaNotInput = !!sr().querySelector('.dialog textarea.dtarea');
c._decorTextDialog = null; await c.updateComplete;
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
c._curSpaceCfg.decor = [
{ id: 'dtl', kind: 'text', x: 0.25, y: 0.25, text: 'Старый', size: 'l', color: '#223344' },
{ id: 'dts', kind: 'text', x: 0.25, y: 0.4, text: 'Мелкий', size: 's', color: '#223344' },
{ id: 'dtm', kind: 'text', x: 0.25, y: 0.55, text: 'Обычный', color: '#223344' },
];
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
out.legacyLarge = +attr('dtl', 'font-size') === 30;
out.legacySmall = +attr('dts', 'font-size') === 14;
out.noSizeIsMedium = +attr('dtm', 'font-size') === 20;
// ================= 2. многострочный текст, по центру =====================
c._decorTool = 'text';
c._decorTextDialog = { x: 0.5, y: 0.5, text: ' Гараж\nпод ключ ', color: '#223344' };
c._decorSaveText(); await c.updateComplete;
const multi = c._decorList.find((x) => x.text && x.text.includes('\n'));
out.newlineStored = !!multi && multi.text === 'Гараж\nпод ключ'; // края обрезаны, середина цела
const tsp = tspans(multi.id);
out.twoLinesRendered = tsp.length === 2
&& tsp[0].textContent === 'Гараж' && tsp[1].textContent === 'под ключ';
out.linesCentredHorizontally = tsp.length === 2 && !!el(multi.id)
&& getComputedStyle(el(multi.id)).textAnchor === 'middle'
&& tsp[0].getAttribute('x') === tsp[1].getAttribute('x');
// блок центрирован и по вертикали: якорь ровно между строками
const ay = multi.y * 1000;
const y0 = tsp[0] ? +tsp[0].getAttribute('y') : NaN;
const y1 = tsp[1] ? +tsp[1].getAttribute('y') : NaN;
out.blockCentredVertically = Math.abs((y0 + y1) / 2 - ay) < 0.01 && y1 > y0;
// длинная строка НЕ переносится сама (docs/LIVE-TEXT.md: никаких автопереносов)
open(multi); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'x'.repeat(120) };
c._decorSaveText(); await c.updateComplete;
out.noAutoWrap = tspans(multi.id).length === 1;
open(c._decorList.find((x) => x.id === multi.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Гараж\nпод ключ' };
c._decorSaveText(); await c.updateComplete;
// ================= 3. ручки масштаба и поворота ==========================
c._decorTool = 'select'; c._decorSel = null; await c.updateComplete;
out.noFrameWithoutSelection = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const frame = () => sr().querySelector('.dtframe');
out.frameOnSelection = !!frame();
out.fourCornersAndRotate = !!frame()
&& frame().querySelectorAll('.dthandle').length === 5
&& !!frame().querySelector('.dtrot');
// линия выделена — рамки текста нет (это рамка ТЕКСТОВОГО блока)
c._curSpaceCfg.decor = [...c._decorList, { id: 'dline', kind: 'line',
x1: 0.1, y1: 0.8, x2: 0.4, y2: 0.8, color: '#000000', width: 3 }];
c._cfgEpoch++; c._decorSel = 'dline'; c.requestUpdate();
await c.updateComplete; await sleep(40); await c.updateComplete;
out.noFrameOnALine = !sr().querySelector('.dtframe');
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
// тянем угол: масштаб растёт пропорционально расстоянию от якоря
const anchor = () => {
const sh = c._decorList.find((x) => x.id === 'dtm');
return [sh.x * 1000, sh.y * 1000];
};
const [ax, ay2] = anchor();
const corner = frame()?.querySelector('.dthandle.dt-nwse') || stageEl();
ev('pointerdown', corner, ax + 40, ay2);
out.dragStarted = !!c._dtDrag && c._dtDrag.kind === 'scale';
ev('pointermove', stageEl(), ax + 80, ay2);
await c.updateComplete;
const scaled = c._decorList.find((x) => x.id === 'dtm');
out.cornerScales = Math.abs((scaled.scale || 0) - 2) < 0.01;
out.fontFollowsScale = +attr('dtm', 'font-size') === 40;
ev('pointerup', stageEl(), ax + 80, ay2);
out.dragEnded = !c._dtDrag;
// старый `size` заменяется масштабом, который он означал (одна правда)
c._decorSel = 'dtl'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [lx, ly] = [c._decorList.find((x) => x.id === 'dtl').x * 1000,
c._decorList.find((x) => x.id === 'dtl').y * 1000];
ev('pointerdown', frame()?.querySelector('.dthandle.dt-nwse') || stageEl(), lx + 50, ly);
ev('pointermove', stageEl(), lx + 100, ly);
await c.updateComplete;
const grown = c._decorList.find((x) => x.id === 'dtl');
out.legacySizeBecomesScale = grown.size === undefined && Math.abs((grown.scale || 0) - 3) < 0.01;
ev('pointerup', stageEl(), lx + 100, ly);
// ручка поворота: шаг 5°, Shift — мимо шага
c._decorSel = 'dtm'; c.requestUpdate();
await c.updateComplete; await sleep(60); await c.updateComplete;
const [rx, ry] = anchor();
const rot = frame()?.querySelector('.dthandle.dtrot') || stageEl();
ev('pointerdown', rot, rx + 100, ry);
out.rotateStarted = c._dtDrag?.kind === 'rotate';
ev('pointermove', stageEl(), rx + 100, ry + 100); // ровно 45°
await c.updateComplete;
out.rotates = Math.abs((c._decorList.find((x) => x.id === 'dtm').angle || 0) - 45) < 1e-6;
ev('pointermove', stageEl(), rx + 100, ry + 2); // ~1.1° → прилипает к 0
await c.updateComplete;
out.snapsTo5 = (c._decorList.find((x) => x.id === 'dtm').angle || 0) === 0;
ev('pointermove', stageEl(), rx + 100, ry + 2, { shiftKey: true });
await c.updateComplete;
const free = c._decorList.find((x) => x.id === 'dtm').angle;
out.shiftGoesFree = free > 0.5 && free < 5; // тот же жест, но точный угол
ev('pointerup', stageEl(), rx + 100, ry + 2);
await c.updateComplete;
out.rotationRendered = /rotate\(/.test(attr('dtm', 'transform') || '');
// …а без Shift тот же 1.1° снова прилипает к нулю
// повернули обратно в ноль — поле исчезает, фигура снова «прямая»
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot') || stageEl(), rx + 100, ry);
ev('pointermove', stageEl(), rx + 100, ry);
ev('pointerup', stageEl(), rx + 100, ry);
await c.updateComplete;
out.zeroAngleDropped = c._decorList.find((x) => x.id === 'dtm').angle === undefined;
// ================= 4. инструмент «текст» по надписи = редактировать =======
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null;
await c.updateComplete;
const before = c._decorList.length;
const target = c._decorList.find((x) => x.id === 'dtm');
out.labelIsLiveUnderTextTool = !!el('dtm') && getComputedStyle(el('dtm')).pointerEvents !== 'none';
ev('pointerdown', el('dtm') || stageEl(), target.x * 1000, target.y * 1000);
await c.updateComplete;
out.textToolEditsTheLabel = c._decorTextDialog?.id === 'dtm'
&& c._decorTextDialog.text === target.text;
out.textToolMakesNoSecondLabel = c._decorList.length === before;
out.textToolDoesNotSelect = c._decorSel === null;
c._decorTextDialog = null; await c.updateComplete;
// по пустому месту — новая надпись
ev('pointerdown', stageEl(), 60, 940);
await c.updateComplete;
out.textToolOnEmptyCreates = !!c._decorTextDialog && !c._decorTextDialog.id;
c._decorTextDialog = null; await c.updateComplete;
// по НЕтекстовой фигуре — тоже новая надпись: она осталась инертной
const lineEl = sr().querySelector('.decorlayer line.dshape');
out.lineStillInert = getComputedStyle(lineEl).pointerEvents === 'none';
ev('pointerdown', lineEl, 250, 800);
await c.updateComplete;
out.textToolOnAShapeCreates = !!c._decorTextDialog && !c._decorTextDialog.id
&& c._decorSel === null;
c._decorTextDialog = null;
return out;
});
checkAll(res);
await finish(browser, res);
+70 -6
View File
@@ -1,4 +1,4 @@
import { launch, checkAll, finish } from './serve.mjs';
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -25,14 +25,78 @@ const res = await page.evaluate(async () => {
out.dialogOpens = !!c._spaceDialog;
out.tabNotSwitched = c._space === cur;
c._spaceDialog = null; await c.updateComplete;
// кнопка "+" по-прежнему только в Плане
out.noAddInView = !sr().querySelector('.tab.tabadd');
c._setMode('plan'); await c.updateComplete;
out.addInPlan = !!sr().querySelector('.tab.tabadd');
// «+» — навигационное действие, а не инструмент редактора плана
// (владелец 2026-08-04): кнопка есть во ВСЕХ режимах и кликабельна.
const addBtn = () => sr().querySelector('.tab.tabadd');
const hittable = () => {
const b = addBtn();
if (!b) return 'нет кнопки';
const r = b.getBoundingClientRect();
if (r.width < 8 || r.height < 8) return `кнопка ${r.width}x${r.height}px`;
const hit = sr().elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === b ? true : 'перекрыта';
};
out.addInView = hittable();
for (const m of ['plan', 'devices', 'decor']) {
c._setMode(m); await c.updateComplete;
out['addIn_' + m] = hittable();
}
c._setMode('view'); await c.updateComplete;
// …и открывает именно диалог СОЗДАНИЯ
const b0 = addBtn();
if (b0) { b0.click(); await c.updateComplete; }
out.addOpensCreate = !!c._spaceDialog && c._spaceDialog.mode === 'create';
c._spaceDialog = null; await c.updateComplete;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"alignDelta": 0,
});
await finish(browser, res);
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
// программно и всё ещё ловится автотестами как «есть»)
const kio = 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.__mkHass();
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
document.body.appendChild(c);
await new Promise((r) => setTimeout(r, 350));
c.hass = { ...c.hass }; await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
c.remove();
return out;
});
for (const [k, v] of Object.entries(kio)) check(k, v);
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
await page.setViewportSize({ width: 390, height: 760 });
const narrow = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await new Promise((r) => setTimeout(r, 250));
c.requestUpdate(); await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
const head = sr.querySelector('.head');
const btn = sr.querySelector('.tab.tabadd');
out.narrowAddPresent = !!btn;
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
const cr = sr.querySelector('ha-card').getBoundingClientRect();
const br = btn ? btn.getBoundingClientRect() : null;
out.narrowAddInsideCard = !br ? 'нет кнопки'
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
})();
return out;
});
for (const [k, v] of Object.entries(narrow)) check(k, v);
await finish(browser, { ...res, ...kio, ...narrow });
+140
View File
@@ -0,0 +1,140 @@
// Живой текст в декоре (docs/LIVE-TEXT.md): текстовая фигура может показывать
// значение ОДНОЙ сущности. Шаблон — поле text, место значения — {}; единица по
// умолчанию из сущности; мёртвая/отсутствующая сущность — прочерк «—»; ничего
// не округляем и не переформатируем. Смок проверяет, что значение читается
// живьём (новый hass — новый текст в DOM, без пересоздания карточки), что
// редактор и просмотр рисуют одно и то же и что все три поля переживают
// сохранение конфига.
// ПАДАЕТ на сборке до этой фичи: подстановки нет, в DOM остаётся сырой шаблон.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** текст фигуры в DOM, склеенный из строк (tspan) */
const dom = (id) => {
const el = sr().querySelector(`.decorlayer text.dtext[data-id="${id}"]`);
if (!el) return null;
return [...el.querySelectorAll('tspan')].map((t) => t.textContent).join('\n');
};
/** открыть форму существующей надписи (на старой сборке — двойным щелчком) */
const open = (sh) => (c._decorOpenText ? c._decorOpenText(sh) : c._decorShapeDbl(sh));
/** подменить одно состояние — ровно так, как HA присылает новый hass */
const push = async (id, st) => {
const states = { ...c.hass.states };
if (st === null) delete states[id]; else states[id] = st;
c.hass = { ...c.hass, states };
await c.updateComplete;
};
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
c._curSpaceCfg.decor = [];
// ---------- 1. связанная и обычная надписи -------------------------------
c._decorTextDialog = { x: 0.3, y: 0.3, text: 'Бак {}', color: '#112233',
entity: 'sensor.living_temp', attr: '', unit: '' };
c._decorSaveText(); await c.updateComplete;
const live = c._decorList.find((x) => x.kind === 'text');
out.linkStored = live.entity === 'sensor.living_temp';
out.noEmptyFieldsStored = !('attr' in live) && !('unit' in live);
out.templateStoredRaw = live.text === 'Бак {}'; // в конфиге — ШАБЛОН
out.liveValueRendered = dom(live.id) === 'Бак 22.4 °C';
c._decorTextDialog = { x: 0.6, y: 0.3, text: 'Сауна', color: '#112233' };
c._decorSaveText(); await c.updateComplete;
const plain = c._decorList.find((x) => x.text === 'Сауна');
out.plainUntouched = dom(plain.id) === 'Сауна' && !('entity' in plain);
// ---------- 2. значение живое: новый hass — новый текст -------------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.9',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.updatesOnState = dom(live.id) === 'Бак 23.9 °C';
out.plainStillUntouched = dom(plain.id) === 'Сауна';
// как отдаёт HA: ни округления, ни переформатирования
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '23.94781',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.noRounding = dom(live.id) === 'Бак 23.94781 °C';
// ---------- 3. мёртвая сущность — прочерк, шаблон остаётся ---------------
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unavailable',
attributes: { unit_of_measurement: '°C' } });
out.deadIsDash = dom(live.id) === 'Бак —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: 'unknown', attributes: {} });
out.unknownIsDash = dom(live.id) === 'Бак —';
await push('sensor.living_temp', null);
out.missingIsDash = dom(live.id) === 'Бак —';
await push('sensor.living_temp', { entity_id: 'sensor.living_temp', state: '22.4',
attributes: { unit_of_measurement: '°C', linkquality: 154 } });
out.backAlive = dom(live.id) === 'Бак 22.4 °C';
// ---------- 4. атрибут и единица ----------------------------------------
open(live); await c.updateComplete;
out.dialogHasTheLink = c._decorTextDialog?.id === live.id
&& c._decorTextDialog?.entity === 'sensor.living_temp';
c._decorTextDialog = { ...c._decorTextDialog, attr: 'linkquality' };
c._decorSaveText(); await c.updateComplete;
out.attrRead = dom(live.id) === 'Бак 154';
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, unit: 'LQI' };
c._decorSaveText(); await c.updateComplete;
out.unitOverride = dom(live.id) === 'Бак 154 LQI';
// атрибута нет — прочерк, но остальная надпись жива
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, attr: 'nope', unit: '' };
c._decorSaveText(); await c.updateComplete;
out.missingAttrIsDash = dom(live.id) === 'Бак —';
// ---------- 5. без плейсхолдера значение уезжает в конец ------------------
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Бак', attr: '', unit: '' };
c._decorSaveText(); await c.updateComplete;
out.appendedWithoutSlot = dom(live.id) === 'Бак 22.4 °C';
// ---------- 6. отвязали — снова обычная надпись --------------------------
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, text: 'Бак {}', entity: '' };
c._decorSaveText(); await c.updateComplete;
const unlinked = c._decorList.find((x) => x.id === live.id);
out.linkCleared = !('entity' in unlinked) && !('attr' in unlinked) && !('unit' in unlinked);
out.rawTemplateShown = dom(live.id) === 'Бак {}';
// ---------- 7. просмотр и киоск рисуют то же самое ------------------------
open(unlinked); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, entity: 'sensor.living_temp' };
c._decorSaveText(); await c.updateComplete;
const editorText = dom(live.id);
c._setMode('view'); await c.updateComplete; await sleep(60);
out.sameInViewMode = dom(live.id) === editorText && editorText === 'Бак 22.4 °C';
const kiosk = document.createElement('houseplan-card');
kiosk.setConfig({ type: 'custom:houseplan-card', kiosk: true });
kiosk.hass = c.hass;
document.body.appendChild(kiosk);
await kiosk.updateComplete; await sleep(400); await kiosk.updateComplete;
const kr = kiosk.shadowRoot || kiosk.renderRoot;
const kEl = kr.querySelector(`.decorlayer text.dtext[data-id="${live.id}"]`);
out.sameInKiosk = !!kEl
&& [...kEl.querySelectorAll('tspan')].map((t) => t.textContent).join('\n') === editorText;
kiosk.remove();
// ---------- 8. все три поля переживают запись конфига ---------------------
const sent = [];
const ws = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return ws(m); } };
c._setMode('decor'); await c.updateComplete;
open(c._decorList.find((x) => x.id === live.id)); await c.updateComplete;
c._decorTextDialog = { ...c._decorTextDialog, attr: 'linkquality', unit: 'LQI' };
c._decorSaveText();
await sleep(900);
const set = sent.filter((m) => m.type === 'houseplan/config/set').pop();
const shape = set && (set.config.spaces.find((s) => s.id === c._space).decor || [])
.find((x) => x.id === live.id);
out.roundTrip = !!shape && shape.entity === 'sensor.living_temp'
&& shape.attr === 'linkquality' && shape.unit === 'LQI' && shape.text === 'Бак {}';
return out;
});
checkAll(res);
await finish(browser, res);
+106
View File
@@ -0,0 +1,106 @@
// «Уже загруженные» — прокручиваемый список превью внутри диалога пространства.
// Проверяем НЕ наличие узлов в DOM, а ГЕОМЕТРИЮ: scrollable-контейнер как
// flex-item в .dialog .body (flex column) схлопывается до бордера (min-height
// auto = 0 при overflow != visible), и владелец видит тонкую полоску вместо
// сетки миниатюр. Ровно этот баг уже ловили в .candlist (v1.53.1) — смок там
// смотрел только на DOM и пропустил его.
import { launch, checkAll, finish } from './serve.mjs';
const VIEWPORTS = [
{ name: 'desktop', width: 900, height: 1000 },
{ name: 'mobile', width: 390, height: 780 },
];
const out = {};
for (const vp of VIEWPORTS) {
const { page, browser } = await launch({ width: vp.width, height: vp.height }, 1);
const res = await page.evaluate(async (mode) => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const base = c.hass.callWS;
const mk = (i) => ({
name: `plan${i}.png`,
url: `/api/houseplan/content/plans/_/plan${i}.png`,
size: 100000 + i * 1000,
modified: i,
used_by: [],
});
let serverPlans = [mk(1), mk(2), mk(3), mk(4), mk(5), mk(6)];
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
if (m.type === 'houseplan/content/sign') {
const urls = {}; for (const p of m.paths) urls[p] = p; return { urls };
}
return base(m);
} };
const openPicker = async () => {
c._spaceDialog = { ...c._spaceDialog, source: 'file' };
await c.updateComplete;
await c._toggleServerPlans();
await new Promise((r) => setTimeout(r, 80));
await c.updateComplete;
await new Promise((r) => requestAnimationFrame(() => r()));
};
const geom = (tag) => {
const box = sr().querySelector('.savedplans');
const rows = [...sr().querySelectorAll('.savedplan')];
o[tag + 'Rows'] = rows.length;
if (!box || !rows.length) { o[tag + 'BoxTall'] = false; o[tag + 'FirstVisible'] = false; return; }
const b = box.getBoundingClientRect();
const r0 = rows[0].getBoundingClientRect();
o[tag + 'BoxH'] = Math.round(b.height);
// 2-3 ряда миниатюр: ряд ~40px + отступы
o[tag + 'BoxTall'] = b.height >= 100;
// первая миниатюра целиком внутри контейнера и не нулевая
o[tag + 'FirstVisible'] = r0.height >= 32 && r0.top >= b.top - 1 && r0.bottom <= b.bottom + 1;
// при 6 планах список не влезает целиком — должна быть прокрутка
o[tag + 'Scrolls'] = box.scrollHeight - box.clientHeight > 8;
box.scrollTop = 9999;
o[tag + 'ScrollWorks'] = box.scrollTop > 8;
box.scrollTop = 0;
};
// 1. диалог создания пространства
c._openSpaceDialog('create');
await c.updateComplete;
await openPicker();
geom('create');
// 2. диалог настроек существующего пространства
c._spaceDialog = null; await c.updateComplete;
c._openSpaceDialog('edit', 'f1');
await c.updateComplete;
await openPicker();
geom('edit');
// 3. пусто: аккуратная надпись, а не схлопнутая полоска
serverPlans = [];
c._spaceDialog = { ...c._spaceDialog, pickSaved: false };
await c.updateComplete;
await openPicker();
const empty = sr().querySelector('.savedplans');
o.emptyHasText = !!empty && empty.textContent.trim().length > 0;
o.emptyTall = !!empty && empty.getBoundingClientRect().height >= 24;
o.mode = mode;
return o;
}, vp.name);
for (const [k, v] of Object.entries(res)) out[vp.name + '.' + k] = v;
await browser.close();
}
console.log(JSON.stringify(out, null, 1));
checkAll(out, {
'desktop.createRows': 6,
'desktop.editRows': 6,
'desktop.createBoxH': out['desktop.createBoxH'],
'desktop.editBoxH': out['desktop.editBoxH'],
'desktop.mode': 'desktop',
'mobile.createRows': 6,
'mobile.editRows': 6,
'mobile.createBoxH': out['mobile.createBoxH'],
'mobile.editBoxH': out['mobile.editBoxH'],
'mobile.mode': 'mobile',
});
await finish(null);
+243
View File
@@ -0,0 +1,243 @@
// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
// градиентом». The owner rejected the «shade instead of light» model of
// docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
// of added luminance simply cannot read on white paper, while its BOUNDARY
// can (docs/SUN.md, «The rim»).
//
// Everything this smoke pins down:
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
// run from the ends of the glass along the ray. Never the glass edge,
// never the far edge; the coordinates are checked against the wedge's own
// vertices, so a rim drawn around the whole polygon fails here;
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
// dead from 85 % on — the rim cannot outlive the light it outlines;
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
// any zoom;
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
// editor;
// * and the point of the whole change: on WHITE paper the pixels along the
// wedge's side are measurably darker than the paper on either hand.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
const RAY_FADE_MS = 2000;
const res = await page.evaluate(async (fadeMs) => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
// side edges are the whole story; the second window is deliberately on a
// wall the same sun cannot reach, to prove no rim appears without a wedge.
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain white paper under the wedge
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
await upd();
};
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
off: parseFloat(s.getAttribute('offset')),
color: s.getAttribute('stop-color'),
a: Number(s.getAttribute('stop-opacity')),
}));
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
await setSun(270, 60); // a western sun, square into the west window
const rays = c._sunRaysCache.rays;
out.oneWedgeDrawn = rays.length === 1;
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
&& rays[0].polys[0].length === 4;
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
const rims = rimsOf();
out.rimDrawn = rims.length > 0;
out.twoRimsPerWedge = rims.length === rays.length * 2;
out.rimStrokeIsAGradient = rims.length > 0
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
l.getAttribute('vector-effect') === 'non-scaling-stroke'
&& Number(l.getAttribute('stroke-width')) === 1);
// every rim line must BE a side edge: its two ends are vertices of the
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
out.rimSidesMatchTheWedge = true;
out.rimIsNeverTheGlassOrTheFarEdge = true;
for (let i = 0; i < rays.length; i++) {
const r = rays[i];
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
const verts = r.polys.flat();
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
const ends = mine.map((l) => [
[+l.getAttribute('x1'), +l.getAttribute('y1')],
[+l.getAttribute('x2'), +l.getAttribute('y2')],
]);
// the four expected corners: the glass ends and where their rays stop
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
for (const [src, tip] of want) {
const hit = ends.some(([p, q]) =>
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
if (!hit) out.rimSidesMatchTheWedge = false;
}
for (const [p, q] of ends) {
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
// parallel to the ray (the glass edge and the far edge are not)
const dx = q[0] - p[0], dy = q[1] - p[1];
const L = Math.hypot(dx, dy) || 1;
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
out.rimIsNeverTheGlassOrTheFarEdge = false;
// and it must not be the glass: both ends on the a-b line would mean it
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
}
}
// ---- 2) the rim gradient: same axis, same curve, black ------------------
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
out.rimGradientBlack = true;
out.rimGradientSharesTheAxis = true;
out.rimGradientSharesTheCurve = true;
out.rimGradientMonotone = true;
out.rimGradientDeadAt85 = true;
out.rimGradientBrightAtTheGlass = true;
for (let i = 0; i < rays.length; i++) {
const rim = gradOf(`hp-sunrim-${i}`);
const fill = fills[i];
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
for (const k of ['x1', 'y1', 'x2', 'y2']) {
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
}
const rs = stopsOf(rim);
const fs = stopsOf(fill);
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
const peak = rs[0].a;
rs.forEach((s, j) => {
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
if (!black) out.rimGradientBlack = false;
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
// the same normalised curve as the fill, only scaled to its own peak
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
out.rimGradientSharesTheCurve = false;
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
});
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
}
// ---- 3) screen coords for the pixel probe, off the real geometry --------
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
// and the rim are still strong
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => {
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
const p = q.matrixTransform(ctm); return [p.x, p.y];
};
if (rays.length) {
const r = rays[0];
const u = 0.22 * r.len;
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
// one render unit INTO the wedge (toward b) → the screen-space normal
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
const iL = Math.hypot(inw[0], inw[1]) || 1;
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
const vx = q[0] - on[0], vy = q[1] - on[1];
const vL = Math.hypot(vx, vy) || 1;
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
}
// ---- 4) below the threshold and in an editor there is no rim -----------
await setSun(270, 2); // under RAY_ELEVATION_MIN
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
await upd();
out.noRimBelowThreshold = rimsOf().length === 0;
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
await setSun(270, 60);
out.rimIsBackAboveThreshold = rimsOf().length === 2;
c._mode = 'plan';
await upd();
out.noRimInTheEditor = rimsOf().length === 0;
c._mode = 'view';
await upd();
out.rimIsBackInViewMode = rimsOf().length === 2;
// ---- 5) a fully overcast sky takes the rim with the light --------------
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
c.hass = { ...c.hass, states: { ...c.hass.states,
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
c._cfgEpoch++;
await upd();
out.noRimInTheRain = rimsOf().length === 0;
delete cfg.settings.weather_entity;
c._cfgEpoch++;
await upd();
out.rimIsBackInTheSun = rimsOf().length === 2;
return out;
}, RAY_FADE_MS);
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
const probe = res.probe;
delete res.probe;
if (probe) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probe }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + shot;
await img.decode();
const cv = document.createElement('canvas');
cv.width = img.width; cv.height = img.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const d = probe.dpr || 1;
const lum = (t) => {
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
return 0.299 * r + 0.587 * g + 0.114 * b;
};
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
const band = [];
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
const darkest = Math.min(...band);
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
return {
paperIsLight: outside > 200,
wedgeInteriorIsLight: inside > 200,
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
};
}, { shot, probe });
res.rimProfile = px.rimProfile;
delete px.rimProfile;
Object.assign(res, px);
} else {
res.paperIsLight = false;
res.wedgeInteriorIsLight = false;
res.rimIsDarkOnTheEdge = false;
}
const profile = res.rimProfile;
delete res.rimProfile;
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
await finish(browser, checkAll(res));
+120
View File
@@ -0,0 +1,120 @@
// DEV-B703-03: «карточка перезагружается при возврате на вкладку» — часть 2.
// Тёплый ре-маунт (v1.58.0) убрал вуаль, но не сохранял ни ПАН, ни зум
// редактора, ни открытые диалоги: Lovelace пересоздаёт элемент, состояние
// диалога живёт в экземпляре и умирает вместе с ним. Смок воспроизводит
// пересоздание (remove + create на той же странице) и требует:
// 1) вид восстановлен БИТ-В-БИТ — ни один кадр не отличается от прежнего;
// 2) открытый диалог пережил пересоздание вместе с черновиком;
// 3) осознанно закрытый (Esc) — НЕ воскресает;
// 4) подтверждение «Выровнять всё» — НЕ воскресает никогда;
// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит.
// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.();
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
const mk = () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки
c.hass = window.__mkHass();
wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(350);
};
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
const watchView = async (c, zoom0, v0, ms = 700) => {
const bad = [];
const t1 = performance.now();
await new Promise((done) => {
const s = () => {
if (Math.abs(c._zoom - zoom0) > 1e-6 || !same(rect(c), v0)) {
bad.push({ t: Math.round(performance.now() - t1), zoom: c._zoom, v: rect(c) });
}
if (performance.now() - t1 < ms) requestAnimationFrame(s); else done();
};
requestAnimationFrame(s);
});
return bad.length === 0 ? true : `кадр ${bad[0].t}мс: zoom=${bad[0].zoom} view=${JSON.stringify(bad[0].v)} (ждали ${zoom0} / ${JSON.stringify(v0)})`;
};
// ================= A. просмотр: пан+зум и настройки пространства ==========
let c = mk();
await settle(c);
c._applyView(2.4, 260, 720); // зум в угол — вид точно не по центру
c._saveZoom();
c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomA = c._zoom, viewA = rect(c);
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
await c.updateComplete;
out.aDialogOpenBefore = !!c._spaceDialog;
c.remove(); await sleep(20); // ← Lovelace выбрасывает элемент
c = mk(); // ← и создаёт новый
out.aViewBitExact = await watchView(c, zoomA, viewA);
out.aDialogSurvived = !!c._spaceDialog;
out.aDraftSurvived = c._spaceDialog?.title === 'ЧЕРНОВИК-42';
out.aStillSameSpaceMode = c._mode === 'view';
// ---- воскрешение одноразовое: ещё одно пересоздание диалог не вернёт ----
c._spaceDialog = null; await c.updateComplete; // (осознанно закрыли)
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.aNoZombieAfterClose = !c._spaceDialog;
// ================= B. редактор устройств: зум редактора + карточка =======
c._setMode('devices'); await c.updateComplete; await sleep(120);
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomB = c._zoom, viewB = rect(c);
const dev = c._devices.find((d) => d.space === c._space);
c._openMarkerDialog(dev); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'ИМЯ-ЧЕРНОВИК' };
await c.updateComplete;
out.bDialogOpenBefore = !!c._markerDialog;
c.remove(); await sleep(20);
c = mk();
out.bModeRestored = c._mode === 'devices';
out.bViewBitExact = await watchView(c, zoomB, viewB);
out.bDialogSurvived = !!c._markerDialog;
out.bDraftSurvived = c._markerDialog?.name === 'ИМЯ-ЧЕРНОВИК';
// ================= C. Esc = осознанное закрытие ==========================
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete; await sleep(30);
out.cClosedByEsc = !c._markerDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.cNoResurrectionAfterEsc = !c._markerDialog;
// ================= D. «Выровнять всё» не воскресает НИКОГДА ==============
c._setMode('view'); await c.updateComplete; await sleep(60);
c._alignDialog = { report: { moved: 3, maxShiftCm: 2, maxSpace: c._space }, spaces: [], layout: {}, cm: 2, where: '', busy: false };
await c.updateComplete;
out.dAlignOpenBefore = !!c._alignDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.dAlignNotRevived = !c._alignDialog;
c.remove(); wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
+117
View File
@@ -0,0 +1,117 @@
// Регрессия аудита v1.59.0-beta.1 — владение тёплой памяткой.
// Три находки, все три ПАДАЮТ на сборке v1.59.0-beta.1:
// AUD-159B1-01 (P2): две карточки с ИДЕНТИЧНЫМ конфигом на одной странице
// делили один слот памятки (ключ = размер окна × JSON конфига), поэтому
// новый экземпляр на месте A усыновлял вид последнего писателя — соседа B
// (его этаж, режим, зум), а черновик настоящего предшественника съедался
// guard-ом `d.mode !== this._mode`.
// AUD-159B1-02 (P2): быстрая серия пересозданий (A→B→C в одном такте):
// промежуточный B ещё не успел воскресить диалог A, но в своём
// disconnectedCallback сбрасывал `_warmRevivePending` ДО снимка и писал
// `dlg: null` поверх чужого черновика — C не получал ничего.
// AUD-159B1-03 (P3): 10-секундный TTL запрещал воскрешение, но не освобождал
// payload: просроченная запись держала диалог (у пространства это план
// целиком в base64) до перезагрузки страницы.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const HP = customElements.get('houseplan-card');
HP._warmBootReset(400); // TTL диалога 400мс вместо 10с — иначе смок стоит 10 секунд
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
/** Lovelace: setConfig ДО вставки; `before` = вставить в чужой DOM-слот */
const mk = (before) => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' });
c.hass = window.__mkHass();
if (before) wrap.insertBefore(c, before); else wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(250);
};
const near = (a, b) => Math.abs(a - b) < 1e-6;
// ============ A. две одинаковые карточки не делят вид и черновик =========
const A = mk();
await settle(A);
A._applyView(2.15, 300, 420);
A.requestUpdate(); await A.updateComplete; await sleep(60);
const zoomA = A._zoom;
A._openSpaceDialog('edit', A._space); await A.updateComplete;
A._spaceDialog = { ...A._spaceDialog, title: 'DRAFT-FROM-A' };
await A.updateComplete; await sleep(30);
const B = mk(); // вторая карточка, тот же конфиг
await settle(B);
B._setMode('devices'); await B.updateComplete; await sleep(120);
B._applyView(3.35, 200, 200);
B.requestUpdate(); await B.updateComplete; await sleep(60);
const zoomB = B._zoom;
out.aTwoLiveCards = A.isConnected && B.isConnected && zoomB !== zoomA;
// B — самостоятельная карточка, а не наследник A
out.aNeighbourKeptOwnView = B._mode === 'devices' && !B._spaceDialog;
// Lovelace создаёт замену A ДО того, как отсоединит A (порядок аудитора)
const A2 = mk(A);
A.remove();
await sleep(120); await A2.updateComplete; await sleep(60);
out.aReplacementKeepsOwnMode = A2._mode === 'view'; // не 'devices' от B
out.aReplacementKeepsOwnZoom = near(A2._zoom, zoomA); // не 3.35 от B
out.aOwnersDraftRestored = A2._spaceDialog?.title === 'DRAFT-FROM-A';
out.aNeighbourUntouched = B._mode === 'devices' && near(B._zoom, zoomB) && !B._spaceDialog;
A2.remove(); B.remove(); await sleep(20);
// ============ B. быстрый двойной ре-маунт не убивает черновик ============
HP._warmBootReset(400);
let c = mk();
await settle(c);
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-RAPID' };
await c.updateComplete; await sleep(30);
out.bDraftOpenBefore = c._spaceDialog?.title === 'DRAFT-RAPID';
// A(черновик) -> создать B -> убрать A -> создать C -> убрать B — всё в одном
// такте, ни один таймер воскрешения не успевает отработать
const rb = mk(c);
c.remove();
const rc = mk(rb);
rb.remove();
await sleep(150); await rc.updateComplete; await sleep(60);
out.bDraftSurvivedDoubleRemount = rc._spaceDialog?.title === 'DRAFT-RAPID';
rc.remove(); await sleep(20);
// ============ C. просроченный диалог освобождает payload =================
HP._warmBootReset(400);
c = mk();
await settle(c);
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'DRAFT-TTL', planFile: 'x'.repeat(4096) };
await c.updateComplete; await sleep(30);
c.remove(); await sleep(20);
out.cHeldWhileRevivable = HP._warmBootStats().dlgs === 1; // пока TTL идёт — держим
await sleep(900); // TTL 400мс + запас
const st = HP._warmBootStats();
out.cPayloadFreedAfterTtl = st.dlgs === 0;
out.cGeometryKept = st.slots >= 0 && st.keys >= 0; // память не сломана
// и после TTL ничего не воскресает
c = mk(); await sleep(150); await c.updateComplete; await sleep(40);
out.cNoReviveAfterTtl = !c._spaceDialog;
c.remove();
HP._warmBootReset();
wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+352 -253
View File
File diff suppressed because one or more lines are too long
+149
View File
@@ -1,5 +1,154 @@
# Changelog
## v1.59.0-beta.2 — 2026-08-04
The second pre-release of the 1.59 line, and it is about the words you put
on the plan. A decor label can now read a live value off an entity, so a
caption becomes a readout without becoming a template language. The text
block itself lost the choice between three font sizes: it is scaled by its
corners and turned by its handle, like every other object on the plan, and
it may have more than one line. Under that, the three findings of the
beta.1 audit are closed — two identical cards no longer share one warm
memo, a rapid double re-mount no longer eats the draft, and an expired
dialog no longer holds a plan file in memory.
- **A decor label can show a live value (docs/LIVE-TEXT.md).** A text shape
gains three optional fields — `entity`, `attr`, `unit` — and its `text`
becomes a template whose `{}` is where the value lands: `Бак {}` reads
*Бак 68 %*. Without a placeholder the value is appended; without an entity
the label is byte-for-byte the static one it always was. The unit comes
from the entity unless you type your own; a dead, unknown or missing
entity shows an em dash instead of quietly vanishing. Nothing is rounded
or reformatted — the value is what Home Assistant reports, because
rounding belongs to the sensor's `display_precision`. Not a template
language: one value, one place, no syntax to get wrong.
- **The text dialog gained an entity picker, an attribute picker and a live
preview.** The attribute list is the chosen entity's own attributes, the
unit field shows the entity's unit as its placeholder, and the preview is
rendered through the very same substitution the plan uses.
- **A text block is sized by its corners and turned by its handle.** The
choice between three font sizes is gone: select a label and pull a corner
to scale it, or use the handle above it to rotate in 5° steps (Shift for
any angle) — the same mechanics as the backdrop frame. Labels drawn with
the old Small/Medium/Large come back at exactly their old size, and the
first drag replaces that setting with the scale it meant.
- **A label can have more than one line.** The text field is a textarea now:
your line breaks are stored and drawn as line breaks, centred, with the
block growing around its anchor. Nothing wraps by itself — a caption that
reflows on a state change is a caption that jumps around the plan.
- **The text tool edits the label you press on.** Drawing tools own the
canvas (a new line must be able to start on the end of an old one), and
that stays true — with one exception: pressing an existing label with the
text tool opens ITS form instead of starting a second label on top of it.
Empty canvas and non-text shapes still create a new label.
- **Two identical cards on one page no longer share a warm memo
(AUD-159B1-01).** The memo key was the window size and the card config,
which cannot tell two placements of the same config apart: the newer of
them was the last writer, and a card re-created in the OTHER placement
woke up with its neighbour's floor, editor mode and zoom — while the
draft of its real predecessor was eaten by the mode guard. The memo now
keeps one entry per card PLACEMENT: `location.pathname` (the dashboard
view) joins the key, and inside a key the entry is claimed by DOM slot —
the parent element and the index in it — with a live neighbour's entry
never adoptable. When two placements are genuinely indistinguishable
only the settled header height is adopted (it is the same for both),
never the viewport and never the dialog.
- **A rapid double re-mount no longer destroys the draft
(AUD-159B1-02).** `disconnectedCallback()` cleared the «I still owe my
predecessor a dialog» flag BEFORE taking its snapshot, so a middle
instance in an A→B→C rebuild wrote `dlg: null` over a draft it had not
yet restored, and the third instance got nothing. The snapshot now runs
while the flag is still set: an unsaved dialog simply travels down the
chain until one instance lives long enough to reopen it.
- **An expired dialog no longer holds its payload (AUD-159B1-03).** The
10-second revive TTL was only a rule checked at revive time; the entry
itself kept the dialog — for a space dialog that is a whole plan file
as base64 — until the page reloaded. Detaching now arms a guarded
eviction that frees the payload the moment it stops being revivable
(and drops the stale slot, so the next claim is unambiguous again).
## v1.59.0-beta.1 — 2026-08-04
Minor pre-release: the card survives a Lovelace re-mount bit-for-bit —
your pan, your zoom and even the dialog you had open come back with it —
sun rays get a hairline edge so they read on white paper, the «+» that
adds a space leaves the Plan editor for the tab row, and the list of
plans already uploaded to the server stops collapsing into a stripe.
- **The card no longer twitches — and no longer loses your open dialog —
when you come back to the tab (docs/WARM-REMOUNT.md).** Lovelace
re-creates the card element on a websocket reconnect; v1.58.0 removed
the preloader flash, but the new instance still had to guess what the
dead one had been looking at. Measured: the PAN never left the instance
(a view parked in a corner came back re-centred: x=50 → x=250 at
zoom 2.2), and the EDITOR zoom is deliberately not persisted while the
editor MODE is — so a re-mount inside an editor came back at 1.0
instead of 3.0. The warm memo now carries the whole viewport (space,
mode, zoom, the `_view` rect itself, the view-mode snapshot, the
«show far objects» frame, the selected tool and selection, the local
«show hidden» toggle), so the restore is the same RECTANGLE, not the
same zoom number — bit-for-bit, verified frame by frame.
- **An open dialog survives the re-creation with its draft.** The memo is
module state and is never serialised, so the live draft object — a
half-filled device dialog with its uploaded PDFs included — moves over
for free. The rule is «revive the draft, never revive the decision»:
the confirmations for «Align everything to the grid» and for a room
merge are deliberately NOT restored (a modal whose whole content is
«press OK to rewrite your plan» must not greet a returning user), nor
is a tap confirmation (it closes over the dead instance), nor the floor
import wizard (it reopens itself), nor any dialog with a save in flight.
Revival requires the same space and the same mode, happens at most once
(the snapshot is consumed), and only if the previous instance died
within the last 10 s. A dialog closed on purpose — Esc, Cancel or
Save — writes `null` into the memo on the very next render, so it can
never come back (smoke_warm_dialogs).
- **Sun rays get an edge (docs/SUN.md, «The rim»).** On a white plan a
wedge of light was nearly invisible, and no amount of opacity could fix
it: painting light means adding luminance, and white paper has none
left to give. Every lit wedge now carries a 1 px black hairline along
its two SIDE edges — the ones running inward from the ends of the
window. It fades to nothing on exactly the same axis, the same curve
and the same 85 % threshold as the fill (a second gradient built on the
fill's own stops), stays one screen pixel at any zoom
(`non-scaling-stroke`), is cut by the room like the wedge itself, and
lives in the same layer — so the 3° threshold, the two-second fade,
cloud cover, night and the editors govern it without a line of extra
logic. Peak opacity 0.42, picked against a white sheet and the dark
glow canvas alike. The «shade instead of light» model of
docs/SUN-CONTRAST.md was rejected in favour of this; that file now
records the decision and keeps the analysis behind it.
- **The «+» that adds a space is not an editor tool.** The button next to
the floor names only existed inside the Plan editor, so adding a second
floor meant first opening an editor you did not want. Adding a space is
navigation, not markup: the «+» now sits in the tab row in every mode —
View and all three editors — exactly where the per-space gear already
lives, and under the same admin rule. The kiosk is a shop window: the
button is not rendered there at all (its header is `display:none`, and a
hidden-but-present node is still clickable from script). The tab row
wraps as before at 390 px (smoke_gear_tabs measures the overflow).
- **«Already uploaded» shows the plans again.** In both space dialogs
(new space and space settings) the list of plans stored on the server
collapsed into a thin rounded stripe: the rows were rendered, the box
itself was 14 px tall. A scrolling box is a flex item whose automatic
minimum size is zero, and the dialog body is a flex column with a
66 vh cap — so the picker was the one child that could be squeezed to
nothing. It no longer shrinks and keeps a floor of its own; up to five
thumbnails are visible at once and the rest scroll. The empty and
loading states stay readable instead of clipping their own text
(smoke_plan_picker measures the heights — the old smoke only counted
DOM nodes and passed).
## v1.58.0 — 2026-08-04
Minor release: the backdrop picture becomes a movable, scalable object,
+157
View File
@@ -6,6 +6,163 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.59.0-beta.2 — 2026-08-04
Второй пре-релиз ветки 1.59, и он про надписи на плане. Надпись в декоре
умеет показывать живое значение сущности — подпись становится показанием,
не превращаясь в язык шаблонов. Сам текстовый блок лишился выбора из трёх
размеров шрифта: он масштабируется углами и поворачивается ручкой, как
любой другой объект плана, и в нём может быть несколько строк. Под этим
закрыты три находки аудита beta.1 — две одинаковые карточки больше не
делят одну тёплую памятку, быстрый двойной ре-маунт не съедает черновик,
а просроченный диалог не держит в памяти файл плана.
- **Надпись на плане может показывать живое значение (docs/LIVE-TEXT.md).**
У текстовой фигуры появились три необязательных поля — `entity`, `attr`,
`unit`, — а её `text` стал шаблоном, в котором `{}` — место значения:
`Бак {}` читается как *Бак 68 %*. Без плейсхолдера значение добавляется в
конец; без сущности надпись остаётся ровно той статичной надписью, какой
была. Единица берётся из сущности, если не задать свою; мёртвая,
неизвестная или отсутствующая сущность показывает прочерк, а не исчезает
молча. Ничего не округляется и не переформатируется — значение такое,
каким его отдаёт Home Assistant: округление живёт в `display_precision`
сенсора, и дублировать его здесь означало бы две правды. Это не язык
шаблонов: одно значение, одно место, нечего написать неправильно.
- **В форме надписи появились пикер сущности, пикер атрибута и живой
предпросмотр.** Список атрибутов — собственные атрибуты выбранной
сущности, в поле единицы подсказкой стоит единица сущности, а
предпросмотр рисуется той же подстановкой, что и сам план.
- **Размер текстового блока задаётся углами, поворот — ручкой.** Выбор из
трёх размеров шрифта убран: выделите надпись и тяните угол, чтобы менять
размер, или крутите ручку над блоком — шаг 5°, Shift для любого угла
(механика та же, что у рамки картинки-подложки). Надписи, сделанные со
старыми «Мелкий/Средний/Крупный», возвращаются ровно в своём прежнем
размере, а первая же протяжка заменяет эту настройку тем масштабом,
который она означала.
- **В надписи может быть несколько строк.** Поле текста стало
многострочным: ваши переводы строк сохраняются и рисуются как переводы
строк, по центру, а блок растёт вокруг своей точки. Автопереносов нет —
надпись, которая перекомпоновывается при каждом изменении состояния, это
надпись, которая прыгает по плану.
- **Инструмент «текст» открывает ту надпись, по которой вы нажали.**
Рисующие инструменты владеют холстом (начало новой линии должно вставать
ровно на конец старой), и это остаётся в силе — с одним исключением:
нажатие на существующую надпись инструментом «текст» открывает ЕЁ форму,
а не создаёт вторую поверх. По пустому месту и по нетекстовой фигуре
по-прежнему создаётся новая надпись.
- **Две одинаковые карточки на одной странице больше не делят тёплую
памятку (AUD-159B1-01).** Ключом памятки были размер окна и конфиг
карточки — он не различает два РАЗМЕЩЕНИЯ одного и того же конфига:
последним писателем оказывался сосед, и карточка, пересозданная на
своём месте, просыпалась с чужим этажом, чужим режимом и чужим зумом,
а черновик её настоящего предшественника съедался проверкой режима.
Теперь памятка хранит запись на каждое РАЗМЕЩЕНИЕ: в ключ вошёл
`location.pathname` (вид дашборда), а внутри ключа запись занимается
по DOM-слоту — родительский элемент и позиция в нём, — и запись живого
соседа не усыновляется никогда. Если два размещения действительно
неразличимы, усыновляется только осевшая высота шапки (она у них
одинаковая), но не вьюпорт и не диалог.
- **Быстрый двойной ре-маунт больше не уничтожает черновик
(AUD-159B1-02).** `disconnectedCallback()` сбрасывал флаг «я ещё должен
предшественнику диалог» ДО снимка, поэтому промежуточный экземпляр в
цепочке A→B→C писал `dlg: null` поверх черновика, который сам ещё не
успел восстановить, и третьему не доставалось ничего. Снимок теперь
делается, пока флаг ещё поднят: несохранённый диалог просто едет по
цепочке, пока кто-нибудь не проживёт достаточно долго, чтобы его
открыть.
- **Просроченный диалог больше не держит свой payload (AUD-159B1-03).**
10-секундный TTL был правилом, которое проверяли только в момент
воскрешения; сама запись продолжала держать диалог — а у диалога
пространства это целый файл плана в base64 — до перезагрузки страницы.
Отсоединение теперь заводит защищённую очистку, которая освобождает
payload ровно тогда, когда он перестаёт быть воскрешаемым (и убирает
устаревший слот, чтобы следующее усыновление снова было однозначным).
## v1.59.0-beta.1 — 2026-08-04
Минорный пре-релиз: карточка переживает пересоздание в Lovelace
бит-в-бит — панорамирование, зум и даже открытый диалог возвращаются
вместе с ней, — у солнечных лучей появляется тонкая кромка, за счёт
которой их видно на белой бумаге, «+» для добавления пространства
уходит из редактора Плана в ряд вкладок, а список уже загруженных на
сервер планов перестаёт схлопываться в полоску.
- **Карточка больше не дёргается — и не теряет открытый диалог — при
возврате на вкладку (docs/WARM-REMOUNT.md).** Lovelace пересоздаёт
элемент карточки при переподключении websocket; v1.58.0 убрала мигание
прелоадера, но новому экземпляру всё ещё приходилось угадывать, на что
смотрел умерший. Измерено: ПАН не переживал экземпляр вообще (вид,
отъеханный в угол, возвращался в центр: x=50 → x=250 при зуме 2.2), а
зум РЕДАКТОРА намеренно не сохраняется, тогда как РЕЖИМ редактора —
сохраняется; поэтому ре-маунт внутри редактора возвращался на 1.0
вместо 3.0. Тёплая памятка теперь несёт весь вьюпорт (пространство,
режим, зум, сам прямоугольник `_view`, снимок вида для выхода из
редактора, кадр «показать дальние», выбранный инструмент и выделение,
локальный переключатель «показать скрытые») — восстанавливается тот же
ПРЯМОУГОЛЬНИК, а не то же число зума: бит-в-бит, проверено покадрово.
- **Открытый диалог переживает пересоздание вместе с черновиком.**
Памятка — состояние модуля, её никто не сериализует, поэтому живой
объект-черновик (в том числе наполовину заполненный диалог устройства с
уже загруженными PDF) переезжает бесплатно. Правило: **воскрешаем
черновик, не воскрешаем решение** — подтверждения «Выровнять всё по
сетке» и объединения комнат намеренно НЕ восстанавливаются (модалка, всё
содержимое которой «нажмите OK, и я перепишу ваш план», не должна
встречать вернувшегося пользователя), как и подтверждение действия по
тапу (оно держит замыкание на мёртвый экземпляр), мастер импорта этажей
(он открывается сам) и любой диалог с записью в полёте. Воскрешение
требует того же пространства и того же режима, происходит не более
одного раза (снимок съедается) и только если прошлый экземпляр умер не
раньше чем 10 с назад. Осознанно закрытый диалог — Esc, «Отмена» или
«Сохранить» — записывает в памятку `null` на ближайшей же отрисовке,
поэтому вернуться уже не может (smoke_warm_dialogs).
- **У солнечных лучей появилась кромка (docs/SUN.md, «The rim»).** На
белом плане клин света был почти не виден, и прозрачностью это не
лечится: рисовать свет — значит добавлять яркость, а у белой бумаги её
больше нет. Теперь у каждого клина по двум БОКОВЫМ рёбрам — тем, что
уходят вглубь комнаты от концов окна, — идёт тонкая чёрная линия в
1 px. Она гаснет ровно вместе с заливкой: та же ось градиента, та же
кривая, тот же порог 85 % (второй градиент собран на стопах первого),
остаётся ровно в один экранный пиксель при любом зуме
(`non-scaling-stroke`), обрезается комнатой так же, как сам клин, и
живёт в том же слое — поэтому порог 3°, двухсекундная анимация,
облачность, ночь и редакторы управляют ею сами, без единой строки
отдельной логики. Максимальная непрозрачность 0.42 — подобрана и на
белом листе, и на тёмном холсте glow. Модель «тень вместо света» из
docs/SUN-CONTRAST.md отклонена в пользу этой кромки; тот файл теперь
фиксирует решение и хранит анализ, из которого оно выросло.
- **«+» для добавления пространства — не инструмент редактора.** Кнопка
рядом с названиями этажей существовала только внутри редактора Плана,
поэтому добавить второй этаж означало сначала открыть редактор, который
тебе не нужен. Добавление пространства — это навигация, а не разметка:
теперь «+» стоит в ряду вкладок во ВСЕХ режимах — в Просмотре и во всех
трёх редакторах, ровно там же, где уже живёт шестерёнка пространства, и
по тому же правилу (только администратор). Киоск — витрина: там кнопка
не отрисовывается вовсе (шапка у него `display:none`, а скрытый, но
присутствующий узел всё ещё кликабелен из скрипта). Ряд вкладок
по-прежнему переносится на 390 px (smoke_gear_tabs меряет переполнение).
- **«Уже загруженные» снова показывают планы.** В обоих диалогах
пространства (создание и настройки) список планов, сохранённых на
сервере, схлопывался в тонкую скруглённую полоску: строки
отрисовывались, а сам контейнер имел высоту 14 px. Прокручиваемый
блок — это flex-элемент с автоматическим минимальным размером ноль, а
тело диалога — flex-колонка с потолком 66 vh, так что сжать до нуля
можно было именно список. Теперь он не сжимается и держит собственный
минимум: одновременно видно до пяти миниатюр, остальные прокручиваются.
Состояния «пусто» и «загрузка» больше не режут собственный текст
(smoke_plan_picker меряет высоты — старый смок считал только узлы DOM
и проходил).
## v1.58.0 — 2026-08-04
Минорный релиз: картинка-подложка становится объектом, который можно
+179
View File
@@ -0,0 +1,179 @@
# The text block — a decor label that can show an entity's state
Status: **implemented (dev, unreleased).** Code: `src/logic.ts`
(`liveText`, `liveTextValue`, `decorTextScale`, `decorTextLines` — pure,
unit-tested), `src/houseplan-card.ts` (`_renderDecorLayer`,
`_renderTextFrame`, `_renderDecorTextDialog`, the `_dt*` gestures),
`custom_components/houseplan/validation.py` (`DECOR_SCHEMA`, text branch).
Smokes: `demo/smoke_live_text.mjs`, `demo/smoke_decor_text.mjs`;
`demo/smoke_decor.mjs` keeps the surrounding decor contract.
The shape: `{kind:'text', x, y, text, color, scale?, angle?, entity?, attr?,
unit?}` — plus the legacy `size?` that older plans still carry. Everything
after `color` is optional, so **every existing plan validates and renders
unchanged and no migration runs**.
## 1. Why a live label
The plan already answers "which lamps are on" and "how warm is the bedroom"
through icons and room fills. It cannot answer the things a house says in
words: *water tank 68 %*, *garage 12 °C*, *watering tonight at 20:00*,
*firewood left: 3 days*. Today a user who wants that puts a device marker in
"value instead of icon" mode — which gives a badge with a bare number, always
tied to a device with a position, an area and a tap action. What is missing is
the caption on the wall: free-standing text, in the user's own words, that
happens to have a live number in it.
The competing card (ha-floorplan) covers this with `text_set` and it is one of
its most used features; our decor text was one field away from it.
## 2. The live value
A decor text shape has three optional fields:
- `entity` — an entity id whose state is substituted; absent = today's plain
static label, byte-for-byte unchanged;
- `attr` — an attribute name to read instead of `state` (battery level,
current temperature of a climate, position of a cover); absent = the state;
- `unit` — a suffix; absent = the entity's own `unit_of_measurement`, so
`sensor.tank` needs no configuration to read *68 %*.
The `text` field keeps its present meaning and becomes the **template**: the
placeholder `{}` is where the value lands. `Бак {}` → *Бак 68 %*. A template
without a placeholder gets the value appended after a space, so a user who
only picks an entity and types nothing sensible still sees something useful.
**Only the first `{}` is replaced** — one label, one value.
`{}` was chosen over `{{ }}` deliberately: this is a substitution, not a
template language. There is no expression, no condition, no arithmetic — the
moment we accept `{{ states('x') | round(1) }}` we have signed up for the
class of support load that fills the competitor's thread (138 posts of "my
CSS/template does not work"). One value, one place, no syntax to get wrong.
### 2.1. Rendering rules
- The value is read live from `hass` on every render — the same source as the
rest of the card, no polling and no subscriptions of its own. A new `hass`
repaints the label; nothing is re-created.
- **Unavailable / unknown / missing entity** → the value renders as `—` (an
em dash) **and the dash carries no unit** («— °C» is not a reading); the
rest of the template stays. A label that silently disappears when a sensor
dies is worse than one that says "no data": the user must see that the
caption is alive and the sensor is not.
- An attribute that is not on the entity, or that is a dict, renders as the
same dash. A list attribute is joined with `, `; `0` and `false` are values,
not absences.
- Numbers are shown as HA reports them; we do not round, do not reformat and
do not localise decimal separators. Rounding belongs to the sensor's
`display_precision` in HA, and duplicating it here would make two sources of
truth. Imperial/metric is not our business either — the value and the unit
come from HA.
- **The unit is inherited only for the STATE.** With an `attr` the entity's
`unit_of_measurement` is not applied: it describes the state, and a
`battery_level` read off a °C sensor must not come out as «73 °C». An
explicit `unit` always wins; an empty one inherits.
- The value is clipped to `LIVE_TEXT_VALUE_MAX` (60) characters: a caption is
a caption, and an attribute that turns out to be a 4 KB string must not
become the plan's wallpaper.
- Editors and kiosk render it identically; in the decor editor the *live*
value is shown (not the raw template), so the user sees what visitors will
see while positioning it. The read-only `houseplan-space-card` does not
render the decor layer at all — that is unchanged, and out of scope here.
## 3. The block: size, rotation, lines
The old `size: 's'|'m'|'l'` selector is **gone from the dialog**. A caption's
size is not one of three opinions; it is whatever fits the place it is put in.
- **`scale`** — a font multiplier against the base 20 px, written by dragging
a corner of the selected block (select tool). Bounded `0.15…20` in the card
and in the backend.
- **`angle`** — degrees, written by the handle above the block. The step is
**5°**, the same step a device icon rotates in; **Shift** drags past it, as
past every other snap in this card (docs/CANVAS.md §9.4). Rotating back to
zero removes the field, so a straight label stores nothing.
- **No migration for `size`.** A stored `size` is read as the multiplier it
used to render at — `s` = 0.7 (14 px), `m` = 1 (20 px), `l` = 1.5 (30 px) —
so an old label comes back at exactly its old size. An explicit `scale`
wins, and the first corner drag **replaces** `size` with the scale it meant:
a shape never states its size twice. `size` stays in `DECOR_SCHEMA` (still
bounded to the three known values) precisely because old plans keep sending
it.
- **Line breaks are the user's own.** The dialog's field is a textarea; a
newline is stored and rendered as a newline (one `<tspan>` per line, line
height 1.2 em). The label **never wraps by itself** — a caption that reflows
on every state change is a caption that jumps around the plan. A
300-character line stays one line.
- **Multi-line blocks are centred**, horizontally (the decor layer's
`text-anchor: middle`, which single-line labels already used) and
vertically: the anchor `x/y` sits in the middle of the block, so adding a
second line grows the label in both directions instead of pushing the first
one up.
- Both gestures pivot on the **anchor** (`x`/`y`), not on a box corner, so a
label never walks away from the point it was placed at, and a rotated block
still scales along the same axis (a distance from the anchor is invariant
under its own rotation). The frame chrome — dashed outline, four corner
handles, one rotate handle on a stem — reuses the backdrop frame's mechanics
and sizes: chrome that never takes a pointer, handles that always do, sized
at 1.8 % of the visible view so they stay finger-sized at any zoom
(docs/BACKDROP.md §2).
- The frame is measured from the rendered glyphs (`getBBox`), so it appears
one frame after the text and follows every edit of it.
## 4. Tools: what a click does
Decor shapes are inert under a drawing tool — a new line must be able to start
exactly on the end of an old one (owner, 2026-08-04). The **text tool has one
exception**, asked for by the owner on the same day:
| Text tool, press on… | What happens |
|---|---|
| an existing **label** | its editor opens (the same form, prefilled) |
| empty canvas | a new label is created there |
| a **non-text** shape (line, rect, ellipse) | a new label is created there; the shape stays inert |
Under the **select** tool a label is selected and dragged as before, a double
click opens its editor, and the corner/rotate handles appear.
## 5. The dialog
Under the text field:
- a **textarea** (line breaks are content now), saved with the button or
Ctrl/⌘+Enter — plain Enter is a new line;
- a hint that mentions `{}` **only when an entity is chosen** — an unlinked
label must not be burdened with syntax it does not need;
- an **entity picker** with a datalist of all entities — the same control
style the vacuum source and the weather field use;
- an **attribute field**, shown only once an entity is chosen, with a datalist
of that entity's actual attribute names — the user should not have to know
that a climate keeps `current_temperature`;
- a **unit field** whose placeholder is the entity's own unit, so leaving it
empty is the obvious right answer (the placeholder disappears once an
attribute is chosen, because an attribute does not inherit it);
- a **live preview** of the resulting label, rendered through the same
`liveText` the plan uses: one substitution, one truth.
Clearing the entity clears the attribute and the unit with it — a save never
leaves orphan fields in the config.
## 6. Backend
`DECOR_SCHEMA`, text branch: `entity` optional, `None` or an entity id
(`^[a-z0-9_]+\.[a-z0-9_]+$`, ≤ 255); `attr` optional, `None` or a flat name
≤ 64; `unit` optional, `None` or ≤ 16 characters; `scale` optional, finite,
`0.15…20`; `angle` optional, finite, `-360…360`; `text` ≤ 200 characters,
newlines included. Everything optional, so every existing plan validates
unchanged. Tests: `tests_backend/test_validation.py`
(`test_decor_text_live_fields`, `test_decor_text_block_scale_and_angle`).
## 7. What this is not
- **Not a second device marker.** No tap action, no icon, no state class, no
participation in room aggregation (LQI, climate averages) — it is a caption,
not a device. A user who wants an interactive thing puts a marker.
- **Not a template engine** (see §2).
- **Not a multi-entity widget.** One label, one value. Two values are two
labels; that stays honest and costs the user one drag.
- **Not an auto-layout.** No wrapping, no shrink-to-fit: the size is set with
the corners and the lines with the Enter key.
+1 -1
View File
@@ -15,7 +15,7 @@
| Item | State |
|---|---|
| Version | **v1.58.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release, tag `v1.58.0` on `main`, GitHub Release with `prerelease=false`; **no direct deploy** — the home instance picks it up through HACS by tag. Contents: the backdrop picture becomes a movable/scalable object (docs/BACKDROP.md — transform frame in the «Картинка-подложка» tool, even corner scaling, live size in metres, grid snap with a Shift bypass, new optional fields `plan_x`/`plan_y`/`plan_scale`, counted by "fit everything"); **NEW PAPER RULE — the opaque plan sheet is always the room contours**, the picture is drawn on top of the sheet and under the geometry (the "paper = image rectangle" branch is gone); decor drawing tools (line/rect/oval/text) and the backdrop tool no longer select the shape under the cursor — existing figures are inert there, select/erase unchanged; the two P2 fixes from the beta.1 audit of «Выровнять всё по сетке» — the confirmation now promises a true upper bound measured per space in its own `cell_cm` (AUD-158B1-01), and an opening whose only error is its angle is a real change that can be applied (AUD-158B1-02). Shipped through v1.58.0-beta.1 (pre-release, 2026-08-04). Previous stable: v1.57.0 "the infinite canvas" |
| Version | **v1.59.0-beta.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.2` on **`dev`**, GitHub Release with `prerelease=true`; `main` is NOT touched and **nothing is copied to the home instance by hand** — HACS delivers it on the beta channel. Contents on top of beta.1: **live text on a decor label** (docs/LIVE-TEXT.md — optional `entity`/`attr`/`unit` on a text shape, `{}` as the one placeholder, the unit inherited from the entity for the state only, an em dash for a dead entity, no rounding, 60 chars max; the dialog gained an entity picker, an attribute picker and a live preview); **the text block** (docs/LIVE-TEXT.md §3–5 — the three font sizes are gone: corner handles scale it and a handle rotates it in 5° steps with Shift for any angle and the pivot on the anchor, the old `size` is read as the initial scale and replaced by the first drag; explicit line breaks are drawn as line breaks, centred on both axes; the text tool opens the form of the label it is pressed on, non-text shapes stay inert); and the three findings of the beta.1 audit (docs/WARM-REMOUNT.md §1.1–1.3): the warm memo keeps one entry per card PLACEMENT (`location.pathname` in the key, a list of slots inside it with candidates ranked by DOM position) so two identical cards on one page no longer mix owners (AUD-159B1-01), the snapshot is taken BEFORE the flag is cleared so a rapid double re-mount no longer loses the draft (AUD-159B1-02), and an expired entry frees its dialog payload on a timer instead of holding a base64 plan file until reload (AUD-159B1-03). Previous stable: v1.58.0 "the backdrop you can move" |
| 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. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
| 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) |
+53 -93
View File
@@ -1,10 +1,16 @@
# Sunlight that reads on a light plan — the spec
# Sunlight that reads on a light plan — the rejected model
Status: **approved in principle, not implemented.** Planned for the release
after v1.58.0. Owner asked for the spec first (2026-08-04); nothing in this
document is code yet. Builds on docs/SUN.md, which describes what ships today.
Status: **REJECTED by the owner on 2026-08-04.** The model below was
approved in principle a few hours earlier and never became code. The owner
cancelled it in favour of one line out of it — the rim — which IS
implemented and specified in docs/SUN.md, section «The rim».
## The problem
This file is kept for one reason: the analysis of *why* a wedge of light
cannot read on white paper is correct, it is what the rim answers, and it
is the first thing anyone will have to re-derive the next time somebody
proposes «просто сделай лучи поярче».
## The problem (this part still holds)
Today a lit window casts a warm translucent wedge (docs/SUN.md). It reads
beautifully on a dark scene — glow fill, night, a dark theme — and almost
@@ -14,100 +20,54 @@ makes a clear day **white**, and a hand-drawn plan's paper is white too.
The cause is not opacity, it is physics. Painting light means adding
luminance, and white has none left to give. Raising the alpha does not add
contrast, it only tints the paper towards beige until the whole room looks
dirty. Any fix has to take its contrast from something other than brightness.
dirty. Any fix has to take its contrast from something other than
brightness.
## The model: paint the shade, not the light
## What was proposed: paint the shade, not the light
On a light scene the wedges invert. The lit areas stay untouched paper; the
**rest of the room** gets a light, cool veil. Contrast now comes from the
unlit part, which has plenty of room to go darker, and the wedge becomes a
window of clean paper inside a softly shaded room.
On a light scene the wedges would invert. The lit areas stay untouched
paper; the **rest of the room** gets a light, cool veil, so the contrast
comes from the unlit part, which has plenty of room to go darker, and the
wedge becomes a window of clean paper inside a softly shaded room. This is
how architects draw insolation on white sheets, and it is physically
honest: a sunlit room really is brighter where the shaft lands.
This is how architects draw insolation on white sheets, and it stays
physically honest: a sunlit room really is brighter where the shaft lands.
It also rhymes with the mode we already have — glow paints a dark house with
pools of lamp light; this paints a light house with the shade between shafts.
Geometrically it is the same wedge polygon used as a hole — a veil over the
room's polygon, minus the wedges (even-odd, or a mask). `computeSunRays()`
would not change at all; only what we do with the result. Around it the
spec needed: a luminance threshold to choose the model automatically, a
cross-fade whenever that threshold is crossed (theme switch, day turning to
night), a rule keeping the veil *under* the room fills so temperature and
LQI colours keep their identity, and an open question about offering
«свет / тень / авто» explicitly.
Geometrically it is the same wedge polygon, used as a hole: a veil rectangle
over the room's polygon, minus the wedges (an even-odd path, or a mask). The
maths of `computeSunRays()` does not change at all — only what we do with the
result.
**Why the owner dropped it.** It is a second rendering model for one
feature: two code paths, an automatic chooser that must be a pure function
of a background nobody controls, a cross-fade between them, and a veil that
has to coexist with the meaning-carrying room fills without being mistaken
for one — all to solve a problem that a single hairline solves.
## Switching between the two
The spec's other amplifier — a mark on the lit window itself (a thicker
warm stroke plus a short arrow along the sun direction, answering «в какие
окна сейчас светит солнце» without reading the floor) — was not rejected on
its merits. It is simply out of scope; this paragraph is where the idea
stays written down.
Automatic, by the luminance of what the wedge is drawn on (the paper under
the room, or the scene colour where a picture backdrop shows through):
## What was implemented instead (2026-08-04)
- luminance below the threshold → **light model** (today's warm wedge);
- above it → **shade model** (veil with wedge-shaped holes).
A **rim**: a 1 px black hairline along the two SIDE edges of the wedge,
fading to zero on exactly the same gradient axis, the same curve and the
same 85 % threshold as the fill, clipped by the room like the wedge itself,
and living inside the same layer — so the 3° threshold, the two-second
fade, cloud cover and the editors govern it for free.
The threshold sits around mid-grey; the exact value is a visual decision to be
made against real plans, dark and light themes, and the day/night palette.
Whether this is fully automatic or also offered as an explicit choice is an
open question (below).
Light is invisible on white paper; its boundary is not. A boundary costs
one stroke, works on any background, and needs no second model, no
luminance threshold and no cross-fade.
## Two amplifiers, both models
**A rim on the wedge.** Light is invisible but its boundary is not: a hairline
warm stroke along the two side edges of the shaft, fading out with the shaft
itself. It costs one stroke, works on any background, and gives the shape a
"beam" reading even when the fill is nearly transparent. In the shade model
the same rim marks the edge of the veil hole.
**A mark on the window.** Independent of the wedges: a lit window gets a
thicker warm stroke and a short arrow along the sun direction. It answers the
question "which windows is the sun in right now" without reading the floor,
and it survives everything the wedge does not — furniture-heavy plans, room
fills, small kiosk scale, a wedge clipped to almost nothing by a narrow room.
## Colour
The veil is cool and desaturated (a blue-grey), never black: a black veil
reads as a printing defect and fights the room fills. Its opacity is small —
the target is a perceptible step, not a dimmed room; the value is to be tuned
visually, in the same order as the wedge alpha today.
The light model keeps its warm ramp near the horizon (docs/SUN.md), but should
lean on **hue rather than luminance** on medium backgrounds: an amber tint
reads as colour even where it cannot read as brightness.
## What must not break
- **Room fills.** Temperature and LQI fills carry meaning; the veil must not
be mistaken for them, and must not shift their perceived colour more than
marginally. The veil goes under the fills, not over.
- **The 3° threshold and the two-second fade** (docs/SUN.md) apply unchanged
to whichever model is active. Crossing the luminance threshold — a theme
switch, day turning to night — must cross-fade between models, never pop.
- **Cloudiness** keeps dimming the effect, in the shade model by thinning the
veil.
- **Editors** stay neutral, as today: no sun, no day/night.
- **The static card and kiosk** must pick the same model as the full card on
the same plan; the choice is a pure function of the background.
- **prefers-reduced-motion**: no cross-fade, switch instantly.
- **Performance**: one veil path per room per render at most, memoised on the
same key as the wedges.
## Open questions for the owner
1. Automatic switching only, or also an explicit setting «свет / тень / авто»
in the space dialog? (Automatic is one less knob; explicit lets someone on
a light theme keep the warm wedges if they like them.)
2. Does the veil cover the whole room, or only the part of the room the sun
could reach at all (the room's window-facing side)? Whole-room is simpler
and reads as "this room is in shade"; partial is subtler.
3. Should the window mark be always on, or follow the same «Solar rays»
toggle?
## Testing notes (for when this is built)
Unit: the model chooser is a pure function of a colour → assert the switch at
the threshold, both sides, plus junk input. The veil-with-holes geometry:
holes equal the wedges, an unlit room gets a plain veil, a room with no
exterior window gets nothing.
Browser: on a white paper plan a lit wedge is measurably lighter than its
surroundings (sample pixels inside and outside the wedge — the current
implementation would fail this, which is the point); on a dark scene nothing
changes versus today; crossing the threshold cross-fades; room fills keep
their identity; the window mark appears exactly on lit windows.
Full contract and the tuned peak opacity: **docs/SUN.md, «The rim»**.
Implementation: `rayRimEdges()`, `rimStops()`, `rimPeakAlpha()` and
`RIM_MAX_ALPHA` in `src/sun.ts`, plus the `hp-sunrim-N` gradient in
`src/houseplan-card.ts`. Browser proof, including the pixel probe showing
the wedge's side is measurably darker than the paper beside it:
`demo/smoke_sun_rim.mjs`.
+56 -2
View File
@@ -219,6 +219,48 @@ left to draw an edge with.
Clipping by the room is unchanged; only the visible edge changed.
### The rim — a hairline along the sides (owner 2026-08-04)
The fill above is honest and nearly invisible on a light plan. Painting
light means ADDING luminance, and white paper has none left to give:
raising `RAY_MAX_ALPHA` does not buy contrast, it only tints the room
beige. That analysis is kept in docs/SUN-CONTRAST.md, whose «shade
instead of light» answer the owner **rejected** on 2026-08-04 in favour
of its cheap half, verbatim: «тонкая (1px) чёрная граница по бокам
светящегося сектора, которая также плавно уходит в ноль вместе с самим
градиентом». Light is invisible on paper; its BOUNDARY is not.
The contract:
- **Two side edges only.** The rim runs along the two edges that leave
the ends of the glass and travel inward with the ray — `a → a+dir·len`
and `b → b+dir·len`. Never the glass edge `a-b` (that is the source,
not a boundary) and never the far edge (there is nothing left to
outline there — the fill is already at zero, see below).
- **One screen pixel at any zoom**: `stroke-width="1"` plus
`vector-effect="non-scaling-stroke"`, so the hairline is a hairline on
a phone, on a 4K kiosk and at any zoom level of the infinite canvas.
- **Black**, and it dies exactly with the fill. A second gradient
`hp-sunrim-N` is emitted next to `hp-sun-N` with **the same
`x1,y1,x2,y2`** (the wall's inward normal, `depth` long) and **the same
normalised curve** — `rimStops()` returns `rayStops()` by identity, not
by copy, so the two can never drift apart. Only the colour and the peak
differ: `RIM_MAX_ALPHA` = 0.42 at the glass, tuned on the demo rig
against both extremes (below ~0.3 the line vanishes on paper at kiosk
scale, above ~0.5 it reads as an ink contour over the dark glow
canvas). Zero from `RAY_FADE_END` = 85 % on, like the fill.
- **Clipped by the room like the wedge**, and for free: `rayRimEdges()`
cuts the sides out of the ALREADY clipped polygons — a boundary segment
belongs to a side iff both of its ends lie on that side's line —
merging collinear pieces so an unclipped wedge yields exactly two
lines. No `clip-path` enters the sun layer, and light still cannot
cross a wall.
- **The same life as the wedge.** The rim lives inside the same
`<g class="sunlayer">`, so the 3° threshold, the 2 s layer fade,
`prefers-reduced-motion`, cloud cover (`rimPeakAlpha(cloud)`, zero in
the rain), night, the editors and the memo key all apply to it without
a line of extra logic.
### The 3° threshold and the 2-second fade
Wedge opacity does NOT depend on elevation any more — the old ramp-in
@@ -284,8 +326,8 @@ Backend validation: string or null.
## Files
- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
quads + clipping, cloud factor, settings inheritance); unit-tested
in `test/sun.test.mjs`.
quads + clipping, the rim edges and its stops, cloud factor, settings
inheritance); unit-tested in `test/sun.test.mjs`.
- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
stage background, both settings dialogs (compass dial included).
- `src/space-render.ts` — the static card's background only.
@@ -301,8 +343,20 @@ Backend validation: string or null.
DEV-EB173-01 grazing repro end to end (west window 80, elevation 90,
azimuth 190): equal sides of the nominal length, peak alpha at BOTH
ends of the glass, and no wedge at all below `RAY_MIN_COS`.
- `demo/smoke_sun_rim.mjs` — the rim: exactly two lines per wedge, on the
two SIDE edges (their coordinates checked against the wedge's own
vertices), `url(#hp-sunrim-N)` with BLACK stops on the same axis and
the same offsets as the fill, monotone and dead at 85 %,
`vector-effect: non-scaling-stroke` at width 1, gone below 3°, in an
editor and in the rain — and a pixel probe on WHITE paper proving the
side of the wedge is measurably darker than the paper on either hand
(the point of the whole change).
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
`hass` tick with no reload, asserted on the COMPUTED background of the
stage; small steps still glide, big ones catch up at once.
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
- `demo/shot_sun_rim.mjs` — the rim on white paper and on the dark glow
canvas, the same frame at several rim peaks (0 = before), which is how
`RIM_MAX_ALPHA` was chosen: `node demo/shot_sun_rim.mjs <outdir>
[0,0.3,0.42,0.5]`.
+164
View File
@@ -14,6 +14,22 @@
> nothing (external audit T1/T3). If you add a checklist line marked `[manual]`,
> add the failing check in the same commit.
> **⚠ Rule: a new scrollable list inside a dialog is tested by GEOMETRY, never
> by the DOM.** Any new scrolling box or `overflow` container added to a dialog
> MUST get a smoke that measures **the container's own height and the visible
> position of its first item** (`getBoundingClientRect`, and the item's rect
> against the box's rect) — counting rendered rows, or asserting that the nodes
> exist, proves nothing. The failure mode is always the same and always
> invisible to a DOM check: a scrolling box is a flex item whose automatic
> minimum size is zero (`min-height: auto` → 0 for an `overflow` child), and a
> dialog body is a flex column with a height cap, so the box is the one child
> that can be squeezed to a sliver while every row inside it renders happily.
> It has bitten us twice already: the **target search results** in the tap
> action dialog (v1.53.1 — 26 matching automations rendered into a 1 px
> stripe; the smoke counted rows and passed) and the **«Already uploaded»**
> plan picker (dev, unreleased — rows present, box 14 px tall, same story).
> Both smokes measure heights now; write the third one that way from the start.
- [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named
facts via `check`/`checkAll` and exits non-zero on any mismatch or
uncaught in-card exception; the suite runs in CI against a FRESHLY built
@@ -1105,6 +1121,51 @@ require hands on real hardware — they remain for the human pass.
updates on every move, is absent before the drag has any length, and is
gone the moment the shape is committed. Rectangles and ovals show their
bounding box «W × H» instead [auto: smoke_decor]
## The text block on the plan (docs/LIVE-TEXT.md, dev, unreleased)
- [ ] **A label can show a live value**: in the Background editor place a text,
pick an entity in the dialog and write `Бак {}` — the plan shows
«Бак 68 %». Change the sensor: the label follows without any reload. A
label with no entity is untouched, byte for byte
[auto: smoke_live_text + unit logic.test]
- [ ] **The unit comes from the entity**: leave the unit field empty and the
entity's own `unit_of_measurement` is used (its placeholder shows which);
type your own and it wins. Choose an ATTRIBUTE (say `battery_level` on a
°C sensor) and the entity's unit is NOT inherited — an attribute is not
the state [auto: smoke_live_text]
- [ ] **A dead sensor says so**: make the entity unavailable (or delete it) —
the value becomes «—» and the rest of the caption stays. The dash carries
no unit [auto: smoke_live_text]
- [ ] **Nothing is rounded**: a sensor reporting `23.94781` shows `23.94781`.
Rounding is the sensor's `display_precision`, not ours [auto:
smoke_live_text]
- [ ] **The preview is the truth**: the line under the dialog's fields is
rendered by the same substitution the plan uses, so what it shows is what
the plan will show [manual]
- [ ] **The same everywhere**: the label reads identically in View, in the
editors and on a kiosk screen [auto: smoke_live_text]
- [ ] **No font-size choice any more**: the dialog has no Small/Medium/Large.
Select a label and pull a corner — the text scales uniformly about its
anchor; the handle above it turns the block in 5° steps, Shift for any
angle. Rotating back to zero leaves a straight label
[auto: smoke_decor_text]
- [ ] **Old labels keep their size**: a plan made before the handles renders
its Small/Medium/Large labels at exactly the old 14/20/30 px, and the
first corner drag converts that setting into the scale it meant
[auto: smoke_decor_text + unit logic.test]
- [ ] **Enter is a new line**: type two lines in the dialog (Ctrl/⌘+Enter or
the button saves) — the plan shows two lines, centred, growing around the
anchor. A very long single line is NOT wrapped for you
[auto: smoke_decor_text]
- [ ] **The text tool edits the label under the cursor**: with the text tool
selected, press an existing label — its form opens, prefilled, and no
second label is created. Press empty canvas, or a line/rectangle, and a
NEW label is created there instead (non-text shapes stay inert under
drawing tools) [auto: smoke_decor_text, smoke_decor]
- [ ] **A label is still a caption, not a device**: no tap action, no icon, no
part in room averages; it is not offered in any of the device pickers
[manual]
- [ ] **Grazing sunlight** (dev, audit DEV-EB173-01): with `sun_rays` on, set
the sun almost ALONG a wall carrying a window (e.g. a west window,
azimuth 190°, elevation 90° at north_deg 0). The shaft is a true
@@ -1204,3 +1265,106 @@ require hands on real hardware — they remain for the human pass.
renders exactly as before the update — same place, same size. Nothing is
written to its config until the first drag [auto: unit test/backdrop.test.mjs
+ tests_backend/test_validation.py]
## Sun ray rim (docs/SUN.md «The rim», dev, unreleased)
- [ ] **A ray reads on white paper**: with `sun_rays` on and a LIGHT scene
(`bg_mode: daynight` at midday, or a white plan), a lit wedge is bounded
by a thin dark hairline along its two SIDE edges — the ones running
inward from the ends of the window. There is NO line across the glass and
none across the far end; the hairline fades out with the light and is
already gone before the wedge's tip [auto: smoke_sun_rim]
- [ ] **It stays a hairline**: zoom the plan all the way in and all the way out
— the line is one pixel wide at every zoom, never a growing black band.
Check on a phone and on a kiosk display too [auto: smoke_sun_rim
(`non-scaling-stroke`), still: demo/shot_sun_rim.mjs]
- [ ] **It is not an outline on a dark scene**: switch to the glow fill or
night — the rim is a subtle darker edge on the shaft, not a drawn contour
around it [manual, visual]
- [ ] **It lives and dies with the wedge**: below 3° it goes with the wedge in
the same two-second fade (not a frame before, not a frame after); rain or
an overcast `weather_entity` takes it away with the light; the editors
show neither; the kiosk and the plan view agree
[auto: smoke_sun_rim + smoke_sun]
- [ ] **A wall still stops it**: point the sun so a shaft runs into the
opposite wall or into the inner corner of an L — the hairline stops on
the wall exactly where the wedge does, and never continues into the next
room [auto: unit sun.test «a room that cuts the shaft cuts the rim»]
## «Already uploaded» plan picker (dev, unreleased)
- [ ] **«Already uploaded» is a list, not a stripe**: in both space dialogs
(new space and space settings, source = "I have a floor-plan image")
press «Already uploaded» with at least five plans on the server. The box
is a few hundred pixels tall, the first thumbnail is fully visible inside
it, and the rest scroll. With nothing uploaded the box shows its message
instead of clipping it. Repeat at phone width. Measure heights, do not
trust the DOM: the rows were always there, the box was 14 px
[auto: smoke_plan_picker]
## «+» adds a space from anywhere (dev, unreleased)
- [ ] **The button is where the floors are, always**: as an admin, open the
card in View — the «+» sits at the end of the tab row next to the floor
names, is at least icon-sized and actually hittable (nothing overlaps
it), and opens the NEW-space dialog. Repeat in all three editors (Plan,
Devices, Background): the same button in the same place, not only in the
Plan editor as before [auto: smoke_gear_tabs]
- [ ] **A kiosk has no «+»**: a card with `kiosk: true` does not RENDER the
button at all — checking that the header is `display:none` is not
enough, a hidden node is still clickable from script [auto: smoke_gear_tabs]
- [ ] **The tab row still fits a phone**: at 390 px the row wraps, nothing
scrolls sideways out of the card, and the «+» stays inside the card and
hittable [auto: smoke_gear_tabs measures `scrollWidth` vs `clientWidth`]
- [ ] **A non-admin never sees it**: the button follows the same rule as the
per-space gear (`_canEdit`) [manual, needs a non-admin HA user]
## Coming back to the tab (docs/WARM-REMOUNT.md, dev, unreleased)
- [ ] **The view does not twitch**: pan the plan into a corner and zoom in
(say 2.5×), leave the tab for long enough that HA reconnects, come back —
the plan is in exactly the same place at exactly the same scale. Not
«about the same»: the restored viewport is the same rectangle, and the
smoke compares it frame by frame [auto: smoke_warm_dialogs]
- [ ] **The same inside an editor**: do it while the Devices editor is open at
a working zoom (say 350 %) — the editor and its zoom both come back
(before the fix the mode came back and the zoom fell to 100 %). Leaving
the editor afterwards still restores the view-mode viewport
[auto: smoke_warm_dialogs]
- [ ] **An open dialog stays open**: leave the tab with the space settings (or
a device card) open and a field edited but NOT saved — on return the
dialog is still there with the same draft [auto: smoke_warm_dialogs]
- [ ] **A closed dialog stays closed**: close it with Esc (or Cancel, or Save)
and only then leave the tab — nothing reopens on return, and it does not
reappear on a second reconnect either (the snapshot is consumed once)
[auto: smoke_warm_dialogs]
- [ ] **Confirmations are never resurrected**: open «Align everything to the
grid», leave the tab, come back — the confirmation is GONE and the plan
is untouched. Same for the room-merge confirmation. This is deliberate:
a modal whose whole content is «press OK to rewrite your plan» must not
be waiting under a returning user's cursor [auto: smoke_warm_dialogs]
- [ ] **Nothing is revived into the wrong place**: switch to another floor (or
another editor) after the reconnect — a dialog that belonged to the old
space/mode does not appear there [auto: smoke_warm_dialogs (space/mode
guard), manual for the floor switch]
- [ ] **A save in flight is not offered twice**: press Save in the space dialog
and reload/reconnect during the write — the dialog does not come back
with a live Save button; the reloaded config shows the outcome [manual]
- [ ] **Two identical cards keep to themselves**: put the SAME card config
twice on one view, park one in the Devices editor at 350 % and leave the
other in View with an unsaved space dialog, then force a rebuild — each
card comes back with its OWN floor, mode and zoom, and the draft returns
to the card that owned it, not to its neighbour (AUD-159B1-01)
[auto: smoke_warm_owners, section A]
- [ ] **Two dashboard views keep to themselves**: the same card config on two
views of one dashboard — switching between them never carries a viewport
or a dialog across (`location.pathname` is part of the key) [manual]
- [ ] **A rebuild storm keeps the draft**: a dashboard that rebuilds twice in a
row (config churn, a flapping websocket) still returns the unsaved dialog
— the draft travels down the chain of instances (AUD-159B1-02)
[auto: smoke_warm_owners, section B]
- [ ] **A forgotten draft frees its plan file**: open the space dialog with a
plan chosen, leave the view and do not come back — after the 10-second
TTL the memo no longer holds the dialog (a plan is base64 in memory), and
nothing revives afterwards (AUD-159B1-03)
[auto: smoke_warm_owners, section C]
+202
View File
@@ -0,0 +1,202 @@
# Тёплый ре-маунт: возврат на вкладку без «перезагрузки»
> Статус: реализовано (dev, DEV-B703-01…03). Код: `src/houseplan-card.ts`,
> модульная памятка `warmBoot`. Смоки: `demo/smoke_warm_remount.mjs`,
> `demo/smoke_warm_dialogs.mjs`, `demo/smoke_warm_owners.mjs` (владение слотами).
## 1. Что вообще происходит
Lovelace **пересоздаёт элемент карточки** — выбрасывает старый DOM-элемент и
создаёт новый — когда websocket переподключается после долго свёрнутой
вкладки, при перестройке дашборда и при переключении видов. Страница при этом
никуда не девается, но экземпляр карточки умирает, и вместе с ним умирает
**всё, что жило в полях экземпляра**.
Для владельца это выглядит как «карточка перезагрузилась»:
* мигал прелоадер (закрыто DEV-B703-01);
* «чуть-чуть дёргается масштаб» (DEV-B703-03, §2);
* **исчезают открытые диалоговые окна** (DEV-B703-03, §3) — это и есть прямое
доказательство пересоздания: состояние диалога больше нигде не хранится.
Лекарство одно и то же: **модульная памятка** `warmBoot` — обычный `Map`,
который живёт в загруженном JS-модуле, а не в экземпляре. Ключ:
```
`${window.innerWidth}x${window.innerHeight}|${location.pathname}|${JSON.stringify(config)}`
```
Смена размера окна → промах → полный защитный бут (единственный случай, когда
хром HA может реально пересобраться заново). `location.pathname` — это дашборд
И путь вида: две вкладки Lovelace никогда не делят ключ, поэтому карточка,
открытая на другом виде, стартует холодной, а не с чужим вьюпортом. Хеш в ключ
НЕ входит: `#space=` — наш собственный диплинк, а не другое размещение.
Одинаковый конфиг дважды на одном виде ключ по-прежнему не различает — этим и
занимается §1.1.
### 1.1. Одна запись — одно РАЗМЕЩЕНИЕ карточки (AUD-159B1-01)
Пока в памятке лежали два числа (высота шапки и высота сцены), «две одинаковые
карточки неразличимы» было косметикой: высоты у них всё равно совпадают. Как
только в записи появились вьюпорт и диалог, ограничение стало функциональным —
аудит v1.59.0-beta.1 воспроизвёл это так:
1. A: вид, зум 2.15, открытый диалог пространства с черновиком;
2. B (тот же конфиг): «Устройства», зум 3.35 — последний писатель общей записи;
3. Lovelace создаёт замену A′ **до** того, как отсоединит A;
4. A′ усыновляет вьюпорт B, а черновик A потом отбраковывается проверкой
«тот же режим» — пользователь видит чужой экран и теряет несохранённое.
Поэтому значение ключа теперь **список слотов**, по одному на каждое
размещение карточки. В слоте, кроме геометрии/вьюпорта/диалога, лежат:
* `owner` — поколение живого экземпляра, сидящего в слоте;
* `place` — **WeakRef** на родительский элемент, в который карточка была
вставлена (слабая ссылка: памятка не имеет права держать DOM живым);
* `idx` — позиция карточки среди детей этого родителя;
* `live` — сидит ли в слоте кто-то живой.
Слот занимается не в `setConfig` (Lovelace зовёт его **до** вставки элемента,
размещения ещё не существует), а в `connectedCallback` — всё ещё до первого
рендера, поэтому вуаль не мигает. Кандидаты ранжируются:
| Оценка | Что это | Усыновляем |
|---|---|---|
| 4 | тот же родитель и тот же индекс — буквально наш DOM-слот; именно так Lovelace подменяет карточку (предшественник может быть ещё жив) | да |
| 0 | **другое** размещение, владелец которого жив — соседняя карточка | никогда |
| 3 | тот же родитель, индекс сдвинулся, владелец ушёл | да |
| 2 | надгробие, чьё размещение исчезло вместе с поддеревом — обычная перестройка Lovelace | да |
Ничья (два одинаково правдоподобных кандидата) означает: усыновляется **только
осевшая высота шапки** — она у одинаковых карточек и так одинаковая, — но не
вьюпорт и не диалог. Это худший случай: карточка открывается без вуали, но со
своим собственным видом.
Диалог воскрешается **из своего слота**, а не «из записи по ключу», поэтому
чужой черновик физически недостижим.
### 1.2. Черновик едет по цепочке (AUD-159B1-02)
`_warmSnapshot()` не трогает `dlg`, пока у экземпляра поднят
`_warmRevivePending` — «я ещё не забрал диалог предшественника». Раньше
`disconnectedCallback()` сбрасывал этот флаг **до** снимка, и промежуточный
экземпляр в цепочке A→B→C записывал `dlg: null` поверх чужого черновика:
двойное пересоздание в одном такте теряло несохранённый ввод. Теперь снимок
делается **первым**, и черновик просто едет дальше по цепочке, пока кто-нибудь
не проживёт достаточно долго, чтобы его открыть. Регрессия:
`demo/smoke_warm_owners.mjs`, секция B.
### 1.3. TTL освобождает payload, а не только запрещает воскрешение (AUD-159B1-03)
`WARM_REVIVE_MS` (10 c) был правилом, которое проверялось только в момент
воскрешения. Запись при этом продолжала держать объект диалога — а у диалога
пространства это **целый файл плана в base64** (бэкенд разрешает 8 MiB). На
настенном планшете, который не перезагружают неделями, это десятки мегабайт
бесполезного удержания. Теперь отсоединение заводит защищённый таймер: через
`WARM_REVIVE_MS` он обнуляет `dlg`, если у слота та же метка `freed` и тот же
владелец (успел прийти преемник — таймер молчит). Высота и вьюпорт остаются:
это байты, и именно они делают следующий маунт тёплым. Заодно опустевший слот
удаляется из списка, если он там не последний — чтобы следующее усыновление
снова было однозначным.
## 2. Рывок масштаба: фактическая причина
Памятка хранила только `hdrH`/`stageH`. Этого мало, потому что:
1. **Пан (`_view`) не переживал экземпляр вообще.** Единственным, что
восстанавливалось, был зум — из `localStorage` (`LS_ZOOM`, per space).
Новый экземпляр приходил с `_view = null`, `updated()` → `_refitView()`, а
затем `_loadFromServer()` звал `_restoreZoom()`, который **центрирует план**
(`_applyView(z, центр vb)`). Вид, отъеханный в угол, возвращался в центр.
Измерено смоком: до пересоздания `view.x=50, y=493`, после — `x=250, y=293`
при zoom 2.2.
2. **Зум редактора не сохраняется намеренно** (`_saveZoom()` выходит при
`_mode !== 'view'`: рабочий зум редактора — инструмент, а не «как я хочу
смотреть»), **а режим редактора сохраняется** (`LS_NAV`). Значит ре-маунт
внутри редактора возвращался в тот же редактор, но на зуме ПРОСМОТРА:
измерено 3.0 → 1.0.
3. `_viewModeSnap` (виджет «куда вернуться при выходе из редактора») умирал
вместе с экземпляром — выход из редактора после ре-маунта прыгал второй раз.
4. `_showFar` («показать дальние объекты») меняет `_baseVb()`, то есть саму
систему координат, против которой клампится вид.
**Правило теперь:** памятка хранит не «зум», а **весь вьюпорт**:
пространство, режим, зум, сам прямоугольник `_view`, `_viewModeSnap`,
`_showFar`, а также инструмент редактора (`_tool`, `_decorTool`), выделение
(`_selId`, `_rszSel`, `_decorSel`) и локальный переключатель «показать
скрытые» (`_showHidden`). Восстановление даёт **бит-в-бит тот же прямоугольник**,
а не «тот же зум». `_loadFromServer()` в этом случае **не зовёт**
`_restoreZoom()` — центрирующее восстановление осталось только для настоящей
навигации (хеш/`LS_NAV` привели на другое пространство).
Диплинк `#space=<id>` — явная навигация и по-прежнему сильнее памятки.
Памятка обновляется **на каждом `updated()`** (метод `_warmSnapshot()`), то
есть всегда отражает последний ОТРИСОВАННЫЙ кадр. Родиться запись может только
из осевшей геометрии (`_bootSettled`), поэтому холодный бут по-прежнему платит
полный защитный цикл.
## 3. Диалоги переживают пересоздание
В той же записи памятки лежит `dlg`: `{ kind, space, mode, data }`, где
`data` — **живой объект-черновик**. Памятка — состояние модуля, её никто не
сериализует, поэтому наполовину заполненный диалог устройства вместе с
загруженными PDF переезжает бесплатно и без потерь.
Восстановление (`_warmReviveDialog`) происходит **не в `setConfig`**, а на
следующий такт после `connectedCallback`: Lovelace в момент `setConfig` может
ещё держать старый элемент, а живому владельцу диалог красть нельзя.
Условия воскрешения — все обязательны:
1. запись памятки есть (тёплый ре-маунт, а не холодный старт);
2. `dlg` не пуст — то есть в **последнем отрисованном кадре** прошлого
экземпляра диалог был открыт;
3. прошлый экземпляр **отсоединился** (`freed`), и с этого момента прошло не
больше `WARM_REVIVE_MS` (10 c). Перестройка Lovelace укладывается в один
такт; ушедший на другой дашборд и вернувшийся через полчаса пользователь не
должен встречать диалог, о котором он давно забыл;
4. **то же пространство и тот же режим** (`d.space`/`d.mode`);
5. **одноразово**: `dlg` съедается в момент попытки восстановления. Третий
экземпляр диалог уже не увидит, зомби невозможен.
Осознанное закрытие (Esc / Отмена / Сохранить) чистить памятку отдельно **не
нужно**: следующий же `updated()` запишет `dlg: null`. Это сильнее ручной
чистки — ни один путь закрытия нельзя забыть.
### 3.1. Что восстанавливается с черновиком
`_spaceDialog` (настройки/создание пространства), `_markerDialog` (устройство),
`_settingsDialog` (общие настройки), `_rulesDialog` (правила иконок),
`_openingDialog` (проём), `_decorTextDialog` (надпись), `_roomDialog` вместе со
всей своей обвязкой (`_roomEditId`, `_roomFill`, источники температуры и
влажности, масштабы подписей, `_areaSel`/`_nameSel`, `_pendingSplit`, `_path`).
Информационные попапы — `_infoCard` (карточка устройства) и `_openingInfo` —
восстанавливаются **по id**, а не по объекту: конфиг мог перезагрузиться под
нами, и карточка, отрисованная из устаревшего объекта, была бы враньём. Если
объекта с таким id больше нет — попап просто не открывается.
### 3.2. Что НЕ восстанавливается — и почему
| Не воскрешаем | Причина |
|---|---|
| «**Выровнять всё по сетке**» (`_alignDialog`) | Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из шестерёнки. |
| Подтверждение **объединения комнат** (`_mergeDialog`) | Тот же класс: подтверждение необратимой правки геометрии. |
| Подтверждение действия по тапу (`_tapConfirm`) | Держит замыкание `exec` на **мёртвый** экземпляр. |
| Мастер импорта этажей (`_importDialog`) | `updated()` сам открывает его, пока конфиг пуст; воскрешение удвоило бы очередь. |
| Любой диалог с `busy: true` | Запись/загрузка была в полёте. Новый экземпляр не знает, доехала ли она; показать «Сохранить» ещё раз — это приглашение записать дважды. Правду покажет перезагрузка конфига. |
| Киоск | У киоска памятки нет вообще (`100dvh`, ничего не оседает, редактирование недоступно). |
Правило одной строкой: **воскрешаем черновик, не воскрешаем решение.**
## 4. Что осознанно отложено
Ре-маунт по-прежнему теряет мелкое промежуточное состояние жестов, у которого
нет осмысленного «продолжения» после смены экземпляра: незавершённый контур
рисования вне диалога комнаты (`_path`/`_cursorPt`), стек отмены ресайза
(`_rszUndo`), выбор разреза (`_splitSel`, `_mergeSel`), черновик фигуры декора
(`_decorDraft`), незавершённое перетаскивание подложки (`_bdDrag`), тост
(`_toast`). Все они живут внутри одного жеста; пересоздание элемента жест и так
прерывает.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.58.0",
"version": "1.59.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.58.0",
"version": "1.59.0-beta.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.58.0",
"version": "1.59.0-beta.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+817 -56
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -273,10 +273,16 @@
"decor.fill": "Fill",
"decor.text_title": "Text label",
"decor.text_label": "Text",
"decor.text_size": "Size",
"decor.size_s": "Small",
"decor.size_m": "Medium",
"decor.size_l": "Large",
"decor.text_hint": "A new line here is a new line on the plan; the block is centred. Drag a corner to resize it, the handle above it to turn it.",
"decor.text_hint_live": "{} is where the value lands. Without it the value is added at the end. A new line here is a new line on the plan.",
"decor.live_group": "Live value",
"decor.live_hint": "The label may show one entity's value — as Home Assistant reports it, with no rounding. A dead entity shows a dash.",
"decor.live_entity": "Entity",
"decor.live_entity_ph": "none — a plain label",
"decor.live_attr": "Attribute",
"decor.live_attr_ph": "the state",
"decor.live_unit": "Unit",
"decor.live_preview": "Preview",
"decor.backdrop": "Backdrop image",
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
"decor.backdrop_reset": "Reset the picture",
+10 -4
View File
@@ -273,10 +273,16 @@
"decor.fill": "Залить",
"decor.text_title": "Надпись",
"decor.text_label": "Текст",
"decor.text_size": "Размер",
"decor.size_s": "Мелкий",
"decor.size_m": "Средний",
"decor.size_l": "Крупный",
"decor.text_hint": "Перевод строки здесь — перевод строки на плане; блок выравнивается по центру. Тяните угол — меняет размер, ручка сверху — поворот.",
"decor.text_hint_live": "{} — место, куда встанет значение. Без него значение добавится в конец. Перевод строки здесь — перевод строки на плане.",
"decor.live_group": "Живое значение",
"decor.live_hint": "Надпись может показывать значение одной сущности — так, как его отдаёт Home Assistant, без округления. Мёртвая сущность — прочерк.",
"decor.live_entity": "Сущность",
"decor.live_entity_ph": "нет — обычная надпись",
"decor.live_attr": "Атрибут",
"decor.live_attr_ph": "состояние",
"decor.live_unit": "Единица",
"decor.live_preview": "Предпросмотр",
"decor.backdrop": "Картинка-подложка",
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
"decor.backdrop_reset": "Вернуть картинку",
+99
View File
@@ -910,6 +910,105 @@ export function floorsOf(hass: any): FloorInfo[] {
return list;
}
// ---------------- live text on a decor label (docs/LIVE-TEXT.md) -------------
/** What a dead sensor says. A label that vanishes with its entity is worse
* than one that admits it has no data. */
export const LIVE_TEXT_DASH = '—';
/** A caption is a caption: an attribute that turns out to be a 4 KB string
* must not become the plan's wallpaper. */
export const LIVE_TEXT_VALUE_MAX = 60;
/** The one placeholder. Not `{{ }}`: this is a substitution, not a template
* language — see docs/LIVE-TEXT.md for why that line is where it is. */
export const LIVE_TEXT_SLOT = '{}';
export interface LiveTextLink {
/** entity id whose value lands in the label; absent = a plain static label */
entity?: string | null;
/** attribute to read instead of the state */
attr?: string | null;
/** suffix; absent = the entity's own unit_of_measurement (state only) */
unit?: string | null;
}
/** One attribute/state value as text, or null when there is nothing to show. */
function liveRaw(raw: unknown): string | null {
if (raw === undefined || raw === null) return null;
if (Array.isArray(raw)) {
const s = raw.map((v) => (v === null || v === undefined ? '' : String(v))).join(', ');
return s ? s.slice(0, LIVE_TEXT_VALUE_MAX) : null;
}
if (typeof raw === 'object') return null; // a dict is not a caption
const s = String(raw);
return s === '' ? null : s.slice(0, LIVE_TEXT_VALUE_MAX);
}
/**
* The live value of a linked label, unit included — exactly as HA reports it.
* No rounding, no reformatting, no localised separators: rounding belongs to
* the sensor's `display_precision`, and duplicating it here would make two
* sources of truth (docs/LIVE-TEXT.md).
*
* Unit resolution: an explicit `unit` always wins. An empty one inherits the
* entity's `unit_of_measurement` ONLY when the STATE is being read — a
* `battery_level` attribute on a °C sensor must not come out as «73 °C».
*/
export function liveTextValue(hass: any, link: LiveTextLink | null | undefined): string {
const id = (link?.entity || '').trim();
if (!id) return '';
const st = hass?.states?.[id];
const state = st?.state;
if (!st || state === undefined || state === null || state === ''
|| state === 'unavailable' || state === 'unknown') return LIVE_TEXT_DASH;
const attr = (link?.attr || '').trim();
const val = liveRaw(attr ? st.attributes?.[attr] : state);
if (val === null) return LIVE_TEXT_DASH; // the attribute is not on this entity
const own = attr ? '' : String(st.attributes?.unit_of_measurement ?? '').trim();
const unit = String(link?.unit ?? '').trim() || own;
return unit ? `${val} ${unit}` : val;
}
/**
* The label as it must be painted: the template with its first `{}` replaced
* by the live value. A template without the placeholder gets the value
* appended after a space, so picking an entity and typing nothing sensible
* still shows something useful. Only the FIRST `{}` is substituted — one
* label, one value (docs/LIVE-TEXT.md «not a multi-entity widget»).
* Without an entity the text is returned byte-for-byte.
*/
export function liveText(text: string | null | undefined, link: LiveTextLink | null | undefined, hass: any): string {
const tpl = text ?? '';
if (!(link?.entity || '').trim()) return tpl;
const v = liveTextValue(hass, link);
const i = tpl.indexOf(LIVE_TEXT_SLOT);
if (i >= 0) return tpl.slice(0, i) + v + tpl.slice(i + LIVE_TEXT_SLOT.length);
return tpl ? `${tpl} ${v}` : v;
}
export const DECOR_TEXT_BASE = 20; // px at scale 1 — what 'm' has always been
export const DECOR_TEXT_SCALE_MIN = 0.15;
export const DECOR_TEXT_SCALE_MAX = 20;
/**
* The font multiplier of a decor text shape. `scale` is the field the corner
* handles write; the legacy `size` ('s'|'m'|'l') is read as the multiplier it
* used to render at (14/20/30 px against the base 20), so a label drawn
* before the handles existed comes back at exactly its old size without any
* migration. An explicit `scale` wins — it is the newer, finer statement of
* the same thing, and the first drag replaces `size` with it.
*/
export function decorTextScale(shape: { scale?: unknown; size?: unknown } | null | undefined): number {
const s = Number(shape?.scale);
if (Number.isFinite(s) && s > 0) return Math.min(DECOR_TEXT_SCALE_MAX, Math.max(DECOR_TEXT_SCALE_MIN, s));
const legacy: Record<string, number> = { s: 0.7, m: 1, l: 1.5 };
return legacy[String(shape?.size ?? '')] ?? 1;
}
/** The lines of a decor label. Explicit newlines only: the label never wraps
* by itself, so a caption cannot reflow (and jump) on a state change. */
export function decorTextLines(s: string | null | undefined): string[] {
return String(s ?? '').replace(/\r\n?/g, '\n').split('\n');
}
/** Substitute every occurrence of {name} placeholders in a template string. */
export function subst(s: string, vars?: Record<string, string | number>): string {
if (!vars) return s;
+62
View File
@@ -896,6 +896,15 @@ export const cardStyles = css`
.stage.mode-decor.dtool-ellipse .decorlayer .dshape,
.stage.mode-decor.dtool-text .decorlayer .dshape,
.stage.mode-decor.dtool-backdrop .decorlayer .dshape { pointer-events: none; }
/* ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL
is a target again — pressing it opens its editor instead of starting a
new label on top of the old one. Only labels: a line or a rectangle
under the text tool stays inert, so the press reaches the stage and a
new label is created there. */
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext {
pointer-events: visiblePainted;
cursor: text;
}
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
@@ -945,6 +954,51 @@ export const cardStyles = css`
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
/* the selected text block's frame — same chrome rules as the backdrop's:
the outline never takes a pointer, the handles always do, and they are
finger-sized because this is dragged on a tablet too */
.dtframe .dtbox {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
stroke-dasharray: 7 5;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.85;
}
.dtframe .dtstem {
stroke: var(--hp-accent);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.85;
}
.dtframe .dthandle {
fill: var(--hp-accent);
stroke: #fff;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: all;
touch-action: none;
}
.dtframe .dt-nwse { cursor: nwse-resize; }
.dtframe .dt-nesw { cursor: nesw-resize; }
.dtframe .dtrot { cursor: grab; }
.dtarea {
resize: vertical;
min-height: 3.4em;
font: inherit;
line-height: 1.35;
}
.dtpreview {
padding: 6px 8px;
border-radius: 8px;
background: rgba(127, 127, 127, 0.12);
text-align: center;
white-space: pre-wrap;
word-break: break-word;
font-weight: 600;
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
@@ -1559,6 +1613,14 @@ export const cardStyles = css`
border: 1px solid var(--hp-line);
border-radius: var(--rad-m);
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
/* The same collapse that ate .candlist (v1.53.1): a scroll box is a
flex item whose automatic minimum size is ZERO (overflow != visible),
so inside .dialog .body — a flex column taller than its 66vh cap —
it shrank to a 14px sliver: the rows were in the DOM, the owner saw
a thin rounded stripe under the "Already uploaded" button. Don't
shrink, and keep a floor even when the box is empty or loading. */
flex: 0 0 auto;
min-height: 2.6em;
}
.savedplan {
display: flex;
+105
View File
@@ -359,6 +359,111 @@ export function rayStops(): [number, number][] {
];
}
// ---------------- the rim (owner 2026-08-04) ----------------
/**
* The rim: «тонкая (1px) чёрная граница по бокам светящегося сектора, которая
* также плавно уходит в ноль вместе с самим градиентом».
*
* Why at all: painting light means ADDING luminance, and white paper has none
* left to give (the analysis kept in docs/SUN-CONTRAST.md). The owner rejected
* the «shade instead of light» model that analysis proposed and asked for the
* cheap half of it instead — light is invisible on white, but its BOUNDARY is
* not. One hairline along each side of the shaft gives the wedge a "beam"
* reading on paper without touching the fill, the geometry or anything a dark
* scene already gets right.
*
* Contract (docs/SUN.md, «The rim»):
*
* - only the two SIDE edges — the ones running from the ends of the window
* along `dir`. Never the glass edge (a-b) and never the far edge: those are
* not boundaries of the beam, they are its source and its end;
* - one screen pixel at any zoom (`vector-effect: non-scaling-stroke`);
* - black, and it dies EXACTLY with the fill: same gradient axis (the wall's
* inward normal, `depth` long), same normalised curve `rayStops()`, same
* `RAY_FADE_END` — so no rim can outlive the light it outlines;
* - clipped by the room like the wedge itself, which here is free: the
* segments are cut out of the ALREADY clipped polygons (`rayRimEdges`),
* so no `clip-path` enters the sun layer (docs/SUN.md keeps that promise).
*/
/**
* Peak rim opacity at the glass, before cloud cover. Visually tuned on the
* demo rig at both extremes: it has to make the shaft legible on white paper
* (the whole point) yet not read as an ink outline over the dark glow canvas.
* Below ~0.3 the line disappears on paper at kiosk scale; above ~0.5 it turns
* into a drawn contour on a night scene.
*/
export const RIM_MAX_ALPHA = 0.42;
/** The rim is black — the one thing white paper still has room for. */
export const RIM_COLOR = '#000000';
/** Peak rim opacity for the current cloud cover — dimmed exactly like the fill. */
export function rimPeakAlpha(cloud = 1): number {
return RIM_MAX_ALPHA * clamp01(cloud);
}
/**
* Rim gradient stops — the SAME normalised curve as the fill, by identity and
* not by copy: «прозрачность гаснет ВМЕСТЕ с заливкой ... ровно по той же
* кривой и тому же порогу». Only the peak alpha and the colour differ.
*/
export function rimStops(): [number, number][] {
return rayStops();
}
/**
* The two side edges of a wedge, cut to exactly what the room left of it.
*
* The clipped polygons already contain those edges: a boundary segment belongs
* to a side iff both of its endpoints lie on that side's line (through `a`,
* resp. `b`, along `dir`). Collinear pieces — polyclip readily splits an edge
* at a touching vertex, and an L-shaped room can cut a side into several
* stretches — are projected onto `dir` and merged, so an unclipped wedge
* yields exactly two segments and a clipped one the fewest that cover it.
*
* `eps` is in render units (the canvas is NORM_W = 1000 wide), comfortably
* above polyclip's rounding and far below anything the eye could see.
*/
export function rayRimEdges(ray: SunRay, eps = 1e-4): number[][][] {
const [dx, dy] = ray.dir;
const nx = -dy;
const ny = dx;
const out: number[][][] = [];
for (const src of [ray.a, ray.b]) {
const spans: [number, number][] = [];
for (const poly of ray.polys) {
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
// off the side's line? then this boundary edge is the glass, the far
// edge, or a wall the room cut the wedge with — not a side of the beam
if (Math.abs((p[0] - src[0]) * nx + (p[1] - src[1]) * ny) > eps) continue;
if (Math.abs((q[0] - src[0]) * nx + (q[1] - src[1]) * ny) > eps) continue;
const up = (p[0] - src[0]) * dx + (p[1] - src[1]) * dy;
const uq = (q[0] - src[0]) * dx + (q[1] - src[1]) * dy;
if (Math.abs(uq - up) <= eps) continue; // degenerate sliver
spans.push(up < uq ? [up, uq] : [uq, up]);
}
}
spans.sort((s, t) => s[0] - t[0]);
const merged: [number, number][] = [];
for (const s of spans) {
const last = merged[merged.length - 1];
if (last && s[0] <= last[1] + eps) last[1] = Math.max(last[1], s[1]);
else merged.push([s[0], s[1]]);
}
for (const [u0, u1] of merged) {
out.push([
[src[0] + dx * u0, src[1] + dy * u0],
[src[0] + dx * u1, src[1] + dy * u1],
]);
}
}
return out;
}
/**
* Day/night sky: how far the painted sky may drift from the real sun before
* the card stops gliding and simply JUMPS to the right colour.
+112 -1
View File
@@ -15,7 +15,9 @@ import {
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, paperRoomShapes, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf } from '../test-build/logic.js';
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, openingShoulders, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility, runServiceFor, TOGGLE_SAFE_DOMAINS, coverService, coverMoving, coverEntityOf,
liveText, liveTextValue, decorTextScale, decorTextLines,
LIVE_TEXT_DASH, LIVE_TEXT_VALUE_MAX, DECOR_TEXT_SCALE_MIN, DECOR_TEXT_SCALE_MAX } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
@@ -1190,3 +1192,112 @@ test('openingShoulders: angled wall measures along the wall direction', () => {
assert.equal(off.centered, false);
assert.ok(Math.abs(off.wallCenter[0] - 150) < 1e-9 && Math.abs(off.wallCenter[1] - 200) < 1e-9);
});
// ---------------- live text on a decor label (docs/LIVE-TEXT.md) ------------
const hassLive = {
states: {
'sensor.tank': { state: '68', attributes: { unit_of_measurement: '%' } },
'sensor.plain': { state: '17.4', attributes: {} },
'climate.hall': { state: 'heat', attributes: { current_temperature: 21.5, unit_of_measurement: '°C', preset_modes: ['home', 'away'] } },
'sensor.dead': { state: 'unavailable', attributes: { unit_of_measurement: '%' } },
'sensor.unknown': { state: 'unknown', attributes: {} },
'sensor.blob': { state: 'ok', attributes: { payload: { a: 1 }, zero: 0, no: false, empty: '' } },
},
};
test('liveText: no entity = the text is untouched, byte for byte', () => {
assert.equal(liveText('Кухня', null, hassLive), 'Кухня');
assert.equal(liveText('Бак {}', {}, hassLive), 'Бак {}');
assert.equal(liveText('Бак {}', { entity: ' ' }, hassLive), 'Бак {}');
assert.equal(liveText('', null, hassLive), '');
});
test('liveText: the placeholder is where the value lands', () => {
assert.equal(liveText('Бак {}', { entity: 'sensor.tank' }, hassLive), 'Бак 68 %');
assert.equal(liveText('{} в баке', { entity: 'sensor.tank' }, hassLive), '68 % в баке');
});
test('liveText: no placeholder = the value is appended after a space', () => {
assert.equal(liveText('Бак', { entity: 'sensor.tank' }, hassLive), 'Бак 68 %');
assert.equal(liveText('', { entity: 'sensor.tank' }, hassLive), '68 %', 'empty template = the bare value, no leading space');
});
test('liveText: only the FIRST placeholder is replaced — one label, one value', () => {
assert.equal(liveText('{} и {}', { entity: 'sensor.tank' }, hassLive), '68 % и {}');
});
test('liveText: the placeholder may sit on any line of a multi-line label', () => {
assert.equal(liveText('Бак\n{}', { entity: 'sensor.tank' }, hassLive), 'Бак\n68 %');
});
test('liveTextValue: unit comes from the entity, an explicit one wins', () => {
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank' }), '68 %');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank', unit: 'проц.' }), '68 проц.');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.tank', unit: ' ' }), '68 %', 'blank = inherit');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.plain' }), '17.4', 'no unit on the entity, none added');
});
test('liveTextValue: an attribute never inherits the STATE unit', () => {
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'current_temperature' }), '21.5',
'the entity °C describes the state, not the attribute');
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'current_temperature', unit: '°C' }), '21.5 °C');
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall' }), 'heat °C', 'the state keeps the entity unit');
});
test('liveTextValue: values are shown as HA reports them — no rounding', () => {
const hass = { states: { 'sensor.x': { state: '17.40000', attributes: { unit_of_measurement: '°C' } } } };
assert.equal(liveTextValue(hass, { entity: 'sensor.x' }), '17.40000 °C');
});
test('liveTextValue: a dead or missing entity is a dash, and the dash has no unit', () => {
assert.equal(liveTextValue(hassLive, { entity: 'sensor.dead' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue(hassLive, { entity: 'sensor.unknown' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue(hassLive, { entity: 'sensor.nope' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue({}, { entity: 'sensor.tank' }), LIVE_TEXT_DASH);
assert.equal(liveText('Бак {}', { entity: 'sensor.dead' }, hassLive), 'Бак —', 'the rest of the template stays');
});
test('liveTextValue: a missing attribute on a live entity is a dash too', () => {
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'nope' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'payload' }), LIVE_TEXT_DASH, 'a dict is not a caption');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'empty' }), LIVE_TEXT_DASH);
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'zero' }), '0', '0 is a value, not an absence');
assert.equal(liveTextValue(hassLive, { entity: 'sensor.blob', attr: 'no' }), 'false');
assert.equal(liveTextValue(hassLive, { entity: 'climate.hall', attr: 'preset_modes' }), 'home, away');
});
test('liveTextValue: a runaway value is clipped, so a caption stays a caption', () => {
const hass = { states: { 'sensor.big': { state: 'x'.repeat(500), attributes: {} } } };
const v = liveTextValue(hass, { entity: 'sensor.big' });
assert.equal(v.length, LIVE_TEXT_VALUE_MAX);
assert.ok(liveText('Log: {}', { entity: 'sensor.big' }, hass).length <= 'Log: '.length + LIVE_TEXT_VALUE_MAX);
});
test('decorTextScale: legacy size renders exactly as it used to', () => {
assert.equal(decorTextScale({ size: 's' }), 0.7, '14px against the base 20');
assert.equal(decorTextScale({ size: 'm' }), 1);
assert.equal(decorTextScale({ size: 'l' }), 1.5, '30px against the base 20');
assert.equal(decorTextScale({}), 1, 'a shape with neither is the medium it has always been');
assert.equal(decorTextScale(null), 1);
});
test('decorTextScale: an explicit scale wins over the legacy size and is bounded', () => {
assert.equal(decorTextScale({ size: 's', scale: 2 }), 2);
assert.equal(decorTextScale({ scale: 0 }), 1, '0 is not a scale — fall back');
assert.equal(decorTextScale({ scale: -3 }), 1);
assert.equal(decorTextScale({ scale: 'x' }), 1);
assert.equal(decorTextScale({ scale: 1e9 }), DECOR_TEXT_SCALE_MAX);
assert.equal(decorTextScale({ scale: 1e-9 }), DECOR_TEXT_SCALE_MIN);
});
test('decorTextLines: explicit newlines only, never an automatic wrap', () => {
assert.deepEqual(decorTextLines('a\nb'), ['a', 'b']);
assert.deepEqual(decorTextLines('a\r\nb'), ['a', 'b'], 'CRLF from a pasted text');
assert.deepEqual(decorTextLines('one'), ['one']);
assert.deepEqual(decorTextLines(''), ['']);
assert.deepEqual(decorTextLines('a\n\nb'), ['a', '', 'b'], 'a blank line is a line');
const long = 'x'.repeat(300);
assert.deepEqual(decorTextLines(long), [long], '300 chars stay one line — no auto wrap');
});
+93
View File
@@ -7,6 +7,7 @@ import {
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
RAY_LENGTH_K, RAY_FADE_END, rayStops, RAY_MIN_COS,
rimStops, rimPeakAlpha, rayRimEdges, RIM_MAX_ALPHA, RIM_COLOR,
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
@@ -146,6 +147,98 @@ test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
assert.ok(half[0] <= 0.65, 'past half-dark by two thirds of the way');
});
// ---- the rim (owner 2026-08-04, docs/SUN.md «The rim») -----------------
test('rimStops: the rim dies on exactly the same curve as the fill', () => {
const rim = rimStops();
// «ровно по той же кривой и тому же порогу» — identity, not a copy that can
// drift: if the fill's easing is ever retuned the outline follows it.
assert.deepEqual(rim, rayStops());
assert.ok(near(rim[0][0], 0) && near(rim[0][1], 1), 'brightest at the glass');
assert.ok(near(rim[rim.length - 1][0], 1), 'spans the FULL wedge, like the fill');
for (let i = 1; i < rim.length; i++) {
assert.ok(rim[i][0] > rim[i - 1][0] || rim[i][0] === 1, 'offsets ascend');
assert.ok(rim[i][1] <= rim[i - 1][1], 'the rim never brightens inward');
}
for (const [off, k] of rim) {
if (off >= RAY_FADE_END) assert.equal(k, 0, 'no rim at/after ' + RAY_FADE_END);
else assert.ok(k > 0, 'still drawn at ' + off);
}
// black, and visible on paper without becoming an ink contour on a dark scene
assert.equal(RIM_COLOR, '#000000');
assert.ok(RIM_MAX_ALPHA >= 0.35 && RIM_MAX_ALPHA <= 0.5, 'the owner\'s 0.35..0.5 window');
assert.ok(near(rimPeakAlpha(1), RIM_MAX_ALPHA));
assert.ok(near(rimPeakAlpha(), RIM_MAX_ALPHA)); // clear sky by default
assert.ok(near(rimPeakAlpha(0.4), RIM_MAX_ALPHA * 0.4)); // clouds dim it too
assert.equal(rimPeakAlpha(0), 0); // rain takes it with the light
assert.ok(near(rimPeakAlpha(5), RIM_MAX_ALPHA)); // garbage cloud factor clamps
});
test('rayRimEdges: the two SIDE edges only, cut exactly like the wedge', () => {
// a west window in r1, a western sun square into it — the wedge stays well
// inside the room, so both sides are whole
const [ray] = computeSunRays(ROOMS, [WIN.west], 270, 60, 0);
assert.ok(ray, 'the west window is lit');
const edges = rayRimEdges(ray);
assert.equal(edges.length, 2, 'one line per side, no more');
const far = (s) => [s[0] + ray.dir[0] * ray.len, s[1] + ray.dir[1] * ray.len];
const same = (p, q) => near(p[0], q[0], 1e-6) && near(p[1], q[1], 1e-6);
const has = (s, t) => edges.some(([p, q]) => (same(p, s) && same(q, t)) || (same(p, t) && same(q, s)));
assert.ok(has(ray.a, far(ray.a)), 'the side from a runs the full reach');
assert.ok(has(ray.b, far(ray.b)), 'the side from b runs the full reach');
// never the glass (a-b) and never the far edge: every rim segment is
// parallel to the ray, and both of them are the full length
for (const [p, q] of edges) {
const dx = q[0] - p[0];
const dy = q[1] - p[1];
const L = Math.hypot(dx, dy);
assert.ok(near(L, ray.len, 1e-6), 'a whole side, not a wall of the room');
assert.ok(near((dx / L) * ray.dir[1] - (dy / L) * ray.dir[0], 0, 1e-9), 'parallel to the ray');
}
// ...and the glass edge is NOT among them, however you orient it
assert.ok(!has(ray.a, ray.b), 'the pane of glass is not a rim');
assert.ok(!has(far(ray.a), far(ray.b)), 'the far edge is not a rim either');
});
test('rayRimEdges: a room that cuts the shaft cuts the rim with it', () => {
// the same window in a room only 30 units deep — the wedge (~46 long at 60°)
// hits the far wall, and both rims must stop on it, not carry on in mid-air
const narrow = [{ id: 'n1', poly: [[100, 100], [130, 100], [130, 500], [100, 500]] }];
const [ray] = computeSunRays(narrow, [WIN.west], 270, 60, 0);
assert.ok(ray && ray.len > 30, 'the wedge really is longer than the room');
const edges = rayRimEdges(ray);
assert.equal(edges.length, 2);
for (const [p, q] of edges) {
assert.ok(near(Math.hypot(q[0] - p[0], q[1] - p[1]), 30, 1e-6), 'clipped to the room');
assert.ok(Math.max(p[0], q[0]) <= 130 + 1e-6, 'nothing past the far wall');
}
// and the shortened rim still starts at the glass
assert.ok(edges.some(([p]) => near(p[0], 100, 1e-6) && near(p[1], 270, 1e-6)));
assert.ok(edges.some(([p]) => near(p[0], 100, 1e-6) && near(p[1], 330, 1e-6)));
});
test('rayRimEdges: collinear splinters merge, an empty wedge draws nothing', () => {
const [ray] = computeSunRays(ROOMS, [WIN.west], 270, 60, 0);
// polyclip readily splits a side at a touching vertex; the rim must still be
// ONE line per side, not a string of them
const poly = ray.polys[0];
const split = [];
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
split.push(p, [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]);
}
assert.equal(split.length, 8, 'every edge of the wedge is now two');
const cut = { ...ray, polys: [split] };
const merged = rayRimEdges(cut);
assert.equal(merged.length, 2, 'still one line per side, not four');
for (const [p, q] of merged) {
assert.ok(near(Math.hypot(q[0] - p[0], q[1] - p[1]), ray.len, 1e-6), 'the whole side');
}
// a wedge clipped away to nothing has no rim at all
assert.deepEqual(rayRimEdges({ ...ray, polys: [] }), []);
});
test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
assert.equal(SKY_SNAP_DEG, 3);
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet
+70
View File
@@ -1135,3 +1135,73 @@ class TestVacuum:
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": ok}))
with pytest.raises(Exception):
v.MARKER_SCHEMA(_marker(vacuum={"trail_mode": "sometimes"}))
def test_decor_text_live_fields():
"""docs/LIVE-TEXT.md: a text label may carry an entity, an attribute and a
unit. All three optional and BOUNDED; junk is refused; a label without them
stays valid, so no plan needs a migration."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5, "text": "Бак {}"}
def cfg(extra):
return v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [{**txt, **extra}]}]})
# --- a plain label is untouched by the new fields ---------------------
assert cfg({})["spaces"][0]["decor"][0] == txt
# --- accepted ---------------------------------------------------------
out = cfg({"entity": "sensor.water_tank", "attr": "battery_level", "unit": "%"})
shape = out["spaces"][0]["decor"][0]
assert shape["entity"] == "sensor.water_tank"
assert shape["attr"] == "battery_level"
assert shape["unit"] == "%"
# None is how the dialog clears a field
assert cfg({"entity": None, "attr": None, "unit": None})
assert cfg({"entity": "binary_sensor.a_1", "attr": "x", "unit": "°C"})
# --- refused ----------------------------------------------------------
for bad in ("sensor", "sensor.", ".tank", "Sensor.Tank", "sensor.tank; drop",
"sensor." + "x" * 300, 5, True, ["sensor.a"]):
with pytest.raises(vol.Invalid):
cfg({"entity": bad})
with pytest.raises(vol.Invalid):
cfg({"attr": "a" * (v.MAX_DECOR_ATTR + 1)})
with pytest.raises(vol.Invalid):
cfg({"unit": "u" * (v.MAX_DECOR_UNIT + 1)})
with pytest.raises(vol.Invalid):
cfg({"attr": 7})
with pytest.raises(vol.Invalid):
cfg({"unit": {"a": 1}})
# the template itself keeps its own bound, placeholder or not
assert cfg({"text": "x" * v.MAX_DECOR_TEXT})
with pytest.raises(vol.Invalid):
cfg({"text": "x" * (v.MAX_DECOR_TEXT + 1)})
def test_decor_text_block_scale_and_angle():
"""The dialog no longer offers a font size — the block is scaled by its
corners and rotated by its handle. Both fields optional and bounded; the
legacy `size` is still accepted (and still bounded) so a label drawn before
the handles renders exactly as it did."""
base = {"id": "s1", "title": "S", "view_box": [0, 0, 1, 1], "rooms": []}
txt = {"id": "d", "kind": "text", "x": 0.5, "y": 0.5, "text": "Porch"}
def cfg(extra):
return v.CONFIG_SCHEMA({"spaces": [{**base, "decor": [{**txt, **extra}]}]})
assert cfg({"scale": 1})["spaces"][0]["decor"][0]["scale"] == 1.0
assert cfg({"scale": v.DECOR_TEXT_SCALE_MIN})
assert cfg({"scale": v.DECOR_TEXT_SCALE_MAX})
assert cfg({"angle": 0}) and cfg({"angle": -360}) and cfg({"angle": 360})
assert cfg({"angle": 45.5})["spaces"][0]["decor"][0]["angle"] == 45.5
for bad in (0, -1, v.DECOR_TEXT_SCALE_MAX + 1, float("nan"), float("inf")):
with pytest.raises(vol.Invalid):
cfg({"scale": bad})
for bad in (361, -361, float("nan")):
with pytest.raises(vol.Invalid):
cfg({"angle": bad})
# legacy: still valid, still only the three known values
assert cfg({"size": "s"}) and cfg({"size": "m"}) and cfg({"size": "l"})
with pytest.raises(vol.Invalid):
cfg({"size": "xxl"})
# a multi-line label round-trips with its newlines intact
assert cfg({"text": "Гараж\nпод ключ"})["spaces"][0]["decor"][0]["text"] == "Гараж\nпод ключ"