mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ee80a6a52 | ||
|
|
1397a71f84 |
@@ -4,4 +4,3 @@ test-build/
|
||||
*.log
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.venv-backend/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
+352
-253
File diff suppressed because one or more lines are too long
Vendored
+352
-253
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Минорный релиз: картинка-подложка становится объектом, который можно
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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`). Все они живут внутри одного жеста; пересоздание элемента жест и так
|
||||
прерывает.
|
||||
Generated
+2
-2
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -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
@@ -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": "Вернуть картинку",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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под ключ"
|
||||
|
||||
Reference in New Issue
Block a user