v1.59.0-beta.1: warm remount keeps your view and dialogs, sun ray rim
Validate / hacs (push) Failing after 7s
Validate / hassfest (push) Failing after 7s
Validate / frontend (push) Successful in 2m47s
Validate / backend (push) Failing after 8m13s
Validate / smoke (push) Failing after 22m41s

This commit is contained in:
Matysh
2026-08-04 18:20:47 +03:00
parent 46f20fe4e1
commit 1397a71f84
23 changed files with 2095 additions and 570 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.58.0"
VERSION = "1.59.0-beta.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.58.0"
"version": "1.59.0-beta.1"
}
+64
View File
@@ -0,0 +1,64 @@
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
// the value can be picked by looking rather than by arguing. The alphas other
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
// DOM, which is why a rebuild is not needed to compare them.
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
// `0` is the "before" frame: the wedge alone, with no rim at all.
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
const setup = async (scene) => {
await page.evaluate(async (sc) => {
const c = window.__card;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
sp.openings = [
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain paper, nothing to hide behind
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
c._cfgEpoch++;
c.hass = { ...c.hass, states: { ...c.hass.states,
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 250, elevation: 35 } } } };
c.requestUpdate();
await c.updateComplete;
}, scene);
await page.waitForTimeout(300);
};
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
// scale relative to the current values would collapse to zero the moment the
// 0 frame is taken and never come back.
const setAlpha = async (peak) => {
await page.evaluate((a) => {
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
const stops = [...g.querySelectorAll('stop')];
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
const p0 = g.__base[0] || 1;
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
}
}, peak);
await page.waitForTimeout(120);
};
for (const scene of ['light', 'dark']) {
await setup(scene);
for (const a of ALPHAS) {
await setAlpha(a);
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
}
}
await browser.close();
console.log('shots written to ' + outDir);
+70 -6
View File
@@ -1,4 +1,4 @@
import { launch, checkAll, finish } from './serve.mjs';
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
@@ -25,14 +25,78 @@ const res = await page.evaluate(async () => {
out.dialogOpens = !!c._spaceDialog;
out.tabNotSwitched = c._space === cur;
c._spaceDialog = null; await c.updateComplete;
// кнопка "+" по-прежнему только в Плане
out.noAddInView = !sr().querySelector('.tab.tabadd');
c._setMode('plan'); await c.updateComplete;
out.addInPlan = !!sr().querySelector('.tab.tabadd');
// «+» — навигационное действие, а не инструмент редактора плана
// (владелец 2026-08-04): кнопка есть во ВСЕХ режимах и кликабельна.
const addBtn = () => sr().querySelector('.tab.tabadd');
const hittable = () => {
const b = addBtn();
if (!b) return 'нет кнопки';
const r = b.getBoundingClientRect();
if (r.width < 8 || r.height < 8) return `кнопка ${r.width}x${r.height}px`;
const hit = sr().elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === b ? true : 'перекрыта';
};
out.addInView = hittable();
for (const m of ['plan', 'devices', 'decor']) {
c._setMode(m); await c.updateComplete;
out['addIn_' + m] = hittable();
}
c._setMode('view'); await c.updateComplete;
// …и открывает именно диалог СОЗДАНИЯ
const b0 = addBtn();
if (b0) { b0.click(); await c.updateComplete; }
out.addOpensCreate = !!c._spaceDialog && c._spaceDialog.mode === 'create';
c._spaceDialog = null; await c.updateComplete;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"alignDelta": 0,
});
await finish(browser, res);
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
// программно и всё ещё ловится автотестами как «есть»)
const kio = await page.evaluate(async () => {
const out = {};
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
c.hass = window.__mkHass();
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
document.body.appendChild(c);
await new Promise((r) => setTimeout(r, 350));
c.hass = { ...c.hass }; await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
c.remove();
return out;
});
for (const [k, v] of Object.entries(kio)) check(k, v);
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
await page.setViewportSize({ width: 390, height: 760 });
const narrow = await page.evaluate(async () => {
const out = {};
const c = window.__card;
await new Promise((r) => setTimeout(r, 250));
c.requestUpdate(); await c.updateComplete;
const sr = c.shadowRoot || c.renderRoot;
const head = sr.querySelector('.head');
const btn = sr.querySelector('.tab.tabadd');
out.narrowAddPresent = !!btn;
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
const cr = sr.querySelector('ha-card').getBoundingClientRect();
const br = btn ? btn.getBoundingClientRect() : null;
out.narrowAddInsideCard = !br ? 'нет кнопки'
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
})();
return out;
});
for (const [k, v] of Object.entries(narrow)) check(k, v);
await finish(browser, { ...res, ...kio, ...narrow });
+106
View File
@@ -0,0 +1,106 @@
// «Уже загруженные» — прокручиваемый список превью внутри диалога пространства.
// Проверяем НЕ наличие узлов в DOM, а ГЕОМЕТРИЮ: scrollable-контейнер как
// flex-item в .dialog .body (flex column) схлопывается до бордера (min-height
// auto = 0 при overflow != visible), и владелец видит тонкую полоску вместо
// сетки миниатюр. Ровно этот баг уже ловили в .candlist (v1.53.1) — смок там
// смотрел только на DOM и пропустил его.
import { launch, checkAll, finish } from './serve.mjs';
const VIEWPORTS = [
{ name: 'desktop', width: 900, height: 1000 },
{ name: 'mobile', width: 390, height: 780 },
];
const out = {};
for (const vp of VIEWPORTS) {
const { page, browser } = await launch({ width: vp.width, height: vp.height }, 1);
const res = await page.evaluate(async (mode) => {
const o = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const base = c.hass.callWS;
const mk = (i) => ({
name: `plan${i}.png`,
url: `/api/houseplan/content/plans/_/plan${i}.png`,
size: 100000 + i * 1000,
modified: i,
used_by: [],
});
let serverPlans = [mk(1), mk(2), mk(3), mk(4), mk(5), mk(6)];
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
if (m.type === 'houseplan/content/sign') {
const urls = {}; for (const p of m.paths) urls[p] = p; return { urls };
}
return base(m);
} };
const openPicker = async () => {
c._spaceDialog = { ...c._spaceDialog, source: 'file' };
await c.updateComplete;
await c._toggleServerPlans();
await new Promise((r) => setTimeout(r, 80));
await c.updateComplete;
await new Promise((r) => requestAnimationFrame(() => r()));
};
const geom = (tag) => {
const box = sr().querySelector('.savedplans');
const rows = [...sr().querySelectorAll('.savedplan')];
o[tag + 'Rows'] = rows.length;
if (!box || !rows.length) { o[tag + 'BoxTall'] = false; o[tag + 'FirstVisible'] = false; return; }
const b = box.getBoundingClientRect();
const r0 = rows[0].getBoundingClientRect();
o[tag + 'BoxH'] = Math.round(b.height);
// 2-3 ряда миниатюр: ряд ~40px + отступы
o[tag + 'BoxTall'] = b.height >= 100;
// первая миниатюра целиком внутри контейнера и не нулевая
o[tag + 'FirstVisible'] = r0.height >= 32 && r0.top >= b.top - 1 && r0.bottom <= b.bottom + 1;
// при 6 планах список не влезает целиком — должна быть прокрутка
o[tag + 'Scrolls'] = box.scrollHeight - box.clientHeight > 8;
box.scrollTop = 9999;
o[tag + 'ScrollWorks'] = box.scrollTop > 8;
box.scrollTop = 0;
};
// 1. диалог создания пространства
c._openSpaceDialog('create');
await c.updateComplete;
await openPicker();
geom('create');
// 2. диалог настроек существующего пространства
c._spaceDialog = null; await c.updateComplete;
c._openSpaceDialog('edit', 'f1');
await c.updateComplete;
await openPicker();
geom('edit');
// 3. пусто: аккуратная надпись, а не схлопнутая полоска
serverPlans = [];
c._spaceDialog = { ...c._spaceDialog, pickSaved: false };
await c.updateComplete;
await openPicker();
const empty = sr().querySelector('.savedplans');
o.emptyHasText = !!empty && empty.textContent.trim().length > 0;
o.emptyTall = !!empty && empty.getBoundingClientRect().height >= 24;
o.mode = mode;
return o;
}, vp.name);
for (const [k, v] of Object.entries(res)) out[vp.name + '.' + k] = v;
await browser.close();
}
console.log(JSON.stringify(out, null, 1));
checkAll(out, {
'desktop.createRows': 6,
'desktop.editRows': 6,
'desktop.createBoxH': out['desktop.createBoxH'],
'desktop.editBoxH': out['desktop.editBoxH'],
'desktop.mode': 'desktop',
'mobile.createRows': 6,
'mobile.editRows': 6,
'mobile.createBoxH': out['mobile.createBoxH'],
'mobile.editBoxH': out['mobile.editBoxH'],
'mobile.mode': 'mobile',
});
await finish(null);
+243
View File
@@ -0,0 +1,243 @@
// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
// градиентом». The owner rejected the «shade instead of light» model of
// docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
// of added luminance simply cannot read on white paper, while its BOUNDARY
// can (docs/SUN.md, «The rim»).
//
// Everything this smoke pins down:
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
// run from the ends of the glass along the ray. Never the glass edge,
// never the far edge; the coordinates are checked against the wedge's own
// vertices, so a rim drawn around the whole polygon fails here;
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
// dead from 85 % on — the rim cannot outlive the light it outlines;
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
// any zoom;
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
// editor;
// * and the point of the whole change: on WHITE paper the pixels along the
// wedge's side are measurably darker than the paper on either hand.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
const RAY_FADE_MS = 2000;
const res = await page.evaluate(async (fadeMs) => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const cfg = c._serverCfg;
const sp = cfg.spaces.find((s) => s.id === 'f1');
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
// side edges are the whole story; the second window is deliberately on a
// wall the same sun cannot reach, to prove no rim appears without a wedge.
sp.openings = [
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
];
sp.plan_url = null; // plain white paper under the wedge
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
c._cfgEpoch++;
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
const setSun = async (az, el) => {
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
await upd();
};
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
off: parseFloat(s.getAttribute('offset')),
color: s.getAttribute('stop-color'),
a: Number(s.getAttribute('stop-opacity')),
}));
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
await setSun(270, 60); // a western sun, square into the west window
const rays = c._sunRaysCache.rays;
out.oneWedgeDrawn = rays.length === 1;
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
&& rays[0].polys[0].length === 4;
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
const rims = rimsOf();
out.rimDrawn = rims.length > 0;
out.twoRimsPerWedge = rims.length === rays.length * 2;
out.rimStrokeIsAGradient = rims.length > 0
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
l.getAttribute('vector-effect') === 'non-scaling-stroke'
&& Number(l.getAttribute('stroke-width')) === 1);
// every rim line must BE a side edge: its two ends are vertices of the
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
out.rimSidesMatchTheWedge = true;
out.rimIsNeverTheGlassOrTheFarEdge = true;
for (let i = 0; i < rays.length; i++) {
const r = rays[i];
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
const verts = r.polys.flat();
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
const ends = mine.map((l) => [
[+l.getAttribute('x1'), +l.getAttribute('y1')],
[+l.getAttribute('x2'), +l.getAttribute('y2')],
]);
// the four expected corners: the glass ends and where their rays stop
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
for (const [src, tip] of want) {
const hit = ends.some(([p, q]) =>
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
if (!hit) out.rimSidesMatchTheWedge = false;
}
for (const [p, q] of ends) {
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
// parallel to the ray (the glass edge and the far edge are not)
const dx = q[0] - p[0], dy = q[1] - p[1];
const L = Math.hypot(dx, dy) || 1;
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
out.rimIsNeverTheGlassOrTheFarEdge = false;
// and it must not be the glass: both ends on the a-b line would mean it
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
}
}
// ---- 2) the rim gradient: same axis, same curve, black ------------------
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
out.rimGradientBlack = true;
out.rimGradientSharesTheAxis = true;
out.rimGradientSharesTheCurve = true;
out.rimGradientMonotone = true;
out.rimGradientDeadAt85 = true;
out.rimGradientBrightAtTheGlass = true;
for (let i = 0; i < rays.length; i++) {
const rim = gradOf(`hp-sunrim-${i}`);
const fill = fills[i];
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
for (const k of ['x1', 'y1', 'x2', 'y2']) {
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
}
const rs = stopsOf(rim);
const fs = stopsOf(fill);
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
const peak = rs[0].a;
rs.forEach((s, j) => {
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
if (!black) out.rimGradientBlack = false;
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
// the same normalised curve as the fill, only scaled to its own peak
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
out.rimGradientSharesTheCurve = false;
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
});
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
}
// ---- 3) screen coords for the pixel probe, off the real geometry --------
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
// and the rim are still strong
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
const ctm = svgEl.getScreenCTM();
const toScr = (x, y) => {
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
const p = q.matrixTransform(ctm); return [p.x, p.y];
};
if (rays.length) {
const r = rays[0];
const u = 0.22 * r.len;
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
// one render unit INTO the wedge (toward b) → the screen-space normal
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
const iL = Math.hypot(inw[0], inw[1]) || 1;
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
const vx = q[0] - on[0], vy = q[1] - on[1];
const vL = Math.hypot(vx, vy) || 1;
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
}
// ---- 4) below the threshold and in an editor there is no rim -----------
await setSun(270, 2); // under RAY_ELEVATION_MIN
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
await upd();
out.noRimBelowThreshold = rimsOf().length === 0;
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
await setSun(270, 60);
out.rimIsBackAboveThreshold = rimsOf().length === 2;
c._mode = 'plan';
await upd();
out.noRimInTheEditor = rimsOf().length === 0;
c._mode = 'view';
await upd();
out.rimIsBackInViewMode = rimsOf().length === 2;
// ---- 5) a fully overcast sky takes the rim with the light --------------
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
c.hass = { ...c.hass, states: { ...c.hass.states,
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
c._cfgEpoch++;
await upd();
out.noRimInTheRain = rimsOf().length === 0;
delete cfg.settings.weather_entity;
c._cfgEpoch++;
await upd();
out.rimIsBackInTheSun = rimsOf().length === 2;
return out;
}, RAY_FADE_MS);
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
const probe = res.probe;
delete res.probe;
if (probe) {
const shot = (await page.screenshot()).toString('base64');
const px = await page.evaluate(async ({ shot, probe }) => {
const img = new Image();
img.src = 'data:image/png;base64,' + shot;
await img.decode();
const cv = document.createElement('canvas');
cv.width = img.width; cv.height = img.height;
const ctx = cv.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const d = probe.dpr || 1;
const lum = (t) => {
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
return 0.299 * r + 0.587 * g + 0.114 * b;
};
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
const band = [];
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
const darkest = Math.min(...band);
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
return {
paperIsLight: outside > 200,
wedgeInteriorIsLight: inside > 200,
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
};
}, { shot, probe });
res.rimProfile = px.rimProfile;
delete px.rimProfile;
Object.assign(res, px);
} else {
res.paperIsLight = false;
res.wedgeInteriorIsLight = false;
res.rimIsDarkOnTheEdge = false;
}
const profile = res.rimProfile;
delete res.rimProfile;
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
await finish(browser, checkAll(res));
+120
View File
@@ -0,0 +1,120 @@
// DEV-B703-03: «карточка перезагружается при возврате на вкладку» — часть 2.
// Тёплый ре-маунт (v1.58.0) убрал вуаль, но не сохранял ни ПАН, ни зум
// редактора, ни открытые диалоги: Lovelace пересоздаёт элемент, состояние
// диалога живёт в экземпляре и умирает вместе с ним. Смок воспроизводит
// пересоздание (remove + create на той же странице) и требует:
// 1) вид восстановлен БИТ-В-БИТ — ни один кадр не отличается от прежнего;
// 2) открытый диалог пережил пересоздание вместе с черновиком;
// 3) осознанно закрытый (Esc) — НЕ воскресает;
// 4) подтверждение «Выровнять всё» — НЕ воскресает никогда;
// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит.
// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись).
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const res = await page.evaluate(async () => {
const out = {};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
customElements.get('houseplan-card')?._warmBootReset?.();
localStorage.removeItem('houseplan_card_zoom_v1');
localStorage.removeItem('houseplan_card_nav_v1');
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
document.body.appendChild(wrap);
const mk = () => {
const c = document.createElement('houseplan-card');
c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки
c.hass = window.__mkHass();
wrap.appendChild(c);
return c;
};
const settle = async (c) => {
const t0 = performance.now();
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
await sleep(350);
};
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
const watchView = async (c, zoom0, v0, ms = 700) => {
const bad = [];
const t1 = performance.now();
await new Promise((done) => {
const s = () => {
if (Math.abs(c._zoom - zoom0) > 1e-6 || !same(rect(c), v0)) {
bad.push({ t: Math.round(performance.now() - t1), zoom: c._zoom, v: rect(c) });
}
if (performance.now() - t1 < ms) requestAnimationFrame(s); else done();
};
requestAnimationFrame(s);
});
return bad.length === 0 ? true : `кадр ${bad[0].t}мс: zoom=${bad[0].zoom} view=${JSON.stringify(bad[0].v)} (ждали ${zoom0} / ${JSON.stringify(v0)})`;
};
// ================= A. просмотр: пан+зум и настройки пространства ==========
let c = mk();
await settle(c);
c._applyView(2.4, 260, 720); // зум в угол — вид точно не по центру
c._saveZoom();
c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomA = c._zoom, viewA = rect(c);
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
c._openSpaceDialog('edit', c._space); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
await c.updateComplete;
out.aDialogOpenBefore = !!c._spaceDialog;
c.remove(); await sleep(20); // ← Lovelace выбрасывает элемент
c = mk(); // ← и создаёт новый
out.aViewBitExact = await watchView(c, zoomA, viewA);
out.aDialogSurvived = !!c._spaceDialog;
out.aDraftSurvived = c._spaceDialog?.title === 'ЧЕРНОВИК-42';
out.aStillSameSpaceMode = c._mode === 'view';
// ---- воскрешение одноразовое: ещё одно пересоздание диалог не вернёт ----
c._spaceDialog = null; await c.updateComplete; // (осознанно закрыли)
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.aNoZombieAfterClose = !c._spaceDialog;
// ================= B. редактор устройств: зум редактора + карточка =======
c._setMode('devices'); await c.updateComplete; await sleep(120);
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
const zoomB = c._zoom, viewB = rect(c);
const dev = c._devices.find((d) => d.space === c._space);
c._openMarkerDialog(dev); await c.updateComplete;
c._markerDialog = { ...c._markerDialog, name: 'ИМЯ-ЧЕРНОВИК' };
await c.updateComplete;
out.bDialogOpenBefore = !!c._markerDialog;
c.remove(); await sleep(20);
c = mk();
out.bModeRestored = c._mode === 'devices';
out.bViewBitExact = await watchView(c, zoomB, viewB);
out.bDialogSurvived = !!c._markerDialog;
out.bDraftSurvived = c._markerDialog?.name === 'ИМЯ-ЧЕРНОВИК';
// ================= C. Esc = осознанное закрытие ==========================
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await c.updateComplete; await sleep(30);
out.cClosedByEsc = !c._markerDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.cNoResurrectionAfterEsc = !c._markerDialog;
// ================= D. «Выровнять всё» не воскресает НИКОГДА ==============
c._setMode('view'); await c.updateComplete; await sleep(60);
c._alignDialog = { report: { moved: 3, maxShiftCm: 2, maxSpace: c._space }, spaces: [], layout: {}, cm: 2, where: '', busy: false };
await c.updateComplete;
out.dAlignOpenBefore = !!c._alignDialog;
c.remove(); await sleep(20);
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
out.dAlignNotRevived = !c._alignDialog;
c.remove(); wrap.remove();
return out;
});
for (const [k, v] of Object.entries(res)) check(k, v);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+168 -151
View File
File diff suppressed because one or more lines are too long
+75
View File
@@ -1,5 +1,80 @@
# Changelog
## 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,
+78
View File
@@ -6,6 +6,84 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## 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
Минорный релиз: картинка-подложка становится объектом, который можно
+1 -1
View File
@@ -15,7 +15,7 @@
| Item | State |
|---|---|
| Version | **v1.58.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release, tag `v1.58.0` on `main`, GitHub Release with `prerelease=false`; **no direct deploy** — the home instance picks it up through HACS by tag. Contents: the backdrop picture becomes a movable/scalable object (docs/BACKDROP.md — transform frame in the «Картинка-подложка» tool, even corner scaling, live size in metres, grid snap with a Shift bypass, new optional fields `plan_x`/`plan_y`/`plan_scale`, counted by "fit everything"); **NEW PAPER RULE — the opaque plan sheet is always the room contours**, the picture is drawn on top of the sheet and under the geometry (the "paper = image rectangle" branch is gone); decor drawing tools (line/rect/oval/text) and the backdrop tool no longer select the shape under the cursor — existing figures are inert there, select/erase unchanged; the two P2 fixes from the beta.1 audit of «Выровнять всё по сетке» — the confirmation now promises a true upper bound measured per space in its own `cell_cm` (AUD-158B1-01), and an opening whose only error is its angle is a real change that can be applied (AUD-158B1-02). Shipped through v1.58.0-beta.1 (pre-release, 2026-08-04). Previous stable: v1.57.0 "the infinite canvas" |
| Version | **v1.59.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.1` 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: **warm re-mount** (docs/WARM-REMOUNT.md — a Lovelace re-creation of the card element restores the whole viewport bit-for-bit: space, mode, zoom, the `_view` rect, the view-mode snapshot, the far-objects frame, the tool, the selection and the local «show hidden»; an open dialog is revived WITH its live draft, while destructive confirmations, tap confirmations, the floor-import wizard and any dialog with a save in flight are deliberately not revived; same space + same mode, at most once, within 10 s); **the sun-ray rim** (docs/SUN.md «The rim» — a 1 px black hairline on the two SIDE edges of every lit wedge, fading on the same axis/curve/85 % threshold as the fill, `non-scaling-stroke`, peak alpha 0.42 — light finally reads on white paper; the «shade instead of light» model of docs/SUN-CONTRAST.md was REJECTED and that file now records the decision); the «+» that adds a space moved out of the Plan editor into the tab row in **every** mode (admin-only, absent in kiosk); and the «Already uploaded» plan list no longer collapses to 14 px (a scrolling flex child needs `min-height:0` overridden — docs/TESTING.md now requires new scrollable dialog containers to be smoke-tested BY GEOMETRY, not by node count). Previous stable: v1.58.0 "the backdrop you can move" |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+53 -93
View File
@@ -1,10 +1,16 @@
# Sunlight that reads on a light plan — the spec
# Sunlight that reads on a light plan — the rejected model
Status: **approved in principle, not implemented.** Planned for the release
after v1.58.0. Owner asked for the spec first (2026-08-04); nothing in this
document is code yet. Builds on docs/SUN.md, which describes what ships today.
Status: **REJECTED by the owner on 2026-08-04.** The model below was
approved in principle a few hours earlier and never became code. The owner
cancelled it in favour of one line out of it — the rim — which IS
implemented and specified in docs/SUN.md, section «The rim».
## The problem
This file is kept for one reason: the analysis of *why* a wedge of light
cannot read on white paper is correct, it is what the rim answers, and it
is the first thing anyone will have to re-derive the next time somebody
proposes «просто сделай лучи поярче».
## The problem (this part still holds)
Today a lit window casts a warm translucent wedge (docs/SUN.md). It reads
beautifully on a dark scene — glow fill, night, a dark theme — and almost
@@ -14,100 +20,54 @@ makes a clear day **white**, and a hand-drawn plan's paper is white too.
The cause is not opacity, it is physics. Painting light means adding
luminance, and white has none left to give. Raising the alpha does not add
contrast, it only tints the paper towards beige until the whole room looks
dirty. Any fix has to take its contrast from something other than brightness.
dirty. Any fix has to take its contrast from something other than
brightness.
## The model: paint the shade, not the light
## What was proposed: paint the shade, not the light
On a light scene the wedges invert. The lit areas stay untouched paper; the
**rest of the room** gets a light, cool veil. Contrast now comes from the
unlit part, which has plenty of room to go darker, and the wedge becomes a
window of clean paper inside a softly shaded room.
On a light scene the wedges would invert. The lit areas stay untouched
paper; the **rest of the room** gets a light, cool veil, so the contrast
comes from the unlit part, which has plenty of room to go darker, and the
wedge becomes a window of clean paper inside a softly shaded room. This is
how architects draw insolation on white sheets, and it is physically
honest: a sunlit room really is brighter where the shaft lands.
This is how architects draw insolation on white sheets, and it stays
physically honest: a sunlit room really is brighter where the shaft lands.
It also rhymes with the mode we already have — glow paints a dark house with
pools of lamp light; this paints a light house with the shade between shafts.
Geometrically it is the same wedge polygon used as a hole — a veil over the
room's polygon, minus the wedges (even-odd, or a mask). `computeSunRays()`
would not change at all; only what we do with the result. Around it the
spec needed: a luminance threshold to choose the model automatically, a
cross-fade whenever that threshold is crossed (theme switch, day turning to
night), a rule keeping the veil *under* the room fills so temperature and
LQI colours keep their identity, and an open question about offering
«свет / тень / авто» explicitly.
Geometrically it is the same wedge polygon, used as a hole: a veil rectangle
over the room's polygon, minus the wedges (an even-odd path, or a mask). The
maths of `computeSunRays()` does not change at all — only what we do with the
result.
**Why the owner dropped it.** It is a second rendering model for one
feature: two code paths, an automatic chooser that must be a pure function
of a background nobody controls, a cross-fade between them, and a veil that
has to coexist with the meaning-carrying room fills without being mistaken
for one — all to solve a problem that a single hairline solves.
## Switching between the two
The spec's other amplifier — a mark on the lit window itself (a thicker
warm stroke plus a short arrow along the sun direction, answering «в какие
окна сейчас светит солнце» without reading the floor) — was not rejected on
its merits. It is simply out of scope; this paragraph is where the idea
stays written down.
Automatic, by the luminance of what the wedge is drawn on (the paper under
the room, or the scene colour where a picture backdrop shows through):
## What was implemented instead (2026-08-04)
- luminance below the threshold → **light model** (today's warm wedge);
- above it → **shade model** (veil with wedge-shaped holes).
A **rim**: a 1 px black hairline along the two SIDE edges of the wedge,
fading to zero on exactly the same gradient axis, the same curve and the
same 85 % threshold as the fill, clipped by the room like the wedge itself,
and living inside the same layer — so the 3° threshold, the two-second
fade, cloud cover and the editors govern it for free.
The threshold sits around mid-grey; the exact value is a visual decision to be
made against real plans, dark and light themes, and the day/night palette.
Whether this is fully automatic or also offered as an explicit choice is an
open question (below).
Light is invisible on white paper; its boundary is not. A boundary costs
one stroke, works on any background, and needs no second model, no
luminance threshold and no cross-fade.
## Two amplifiers, both models
**A rim on the wedge.** Light is invisible but its boundary is not: a hairline
warm stroke along the two side edges of the shaft, fading out with the shaft
itself. It costs one stroke, works on any background, and gives the shape a
"beam" reading even when the fill is nearly transparent. In the shade model
the same rim marks the edge of the veil hole.
**A mark on the window.** Independent of the wedges: a lit window gets a
thicker warm stroke and a short arrow along the sun direction. It answers the
question "which windows is the sun in right now" without reading the floor,
and it survives everything the wedge does not — furniture-heavy plans, room
fills, small kiosk scale, a wedge clipped to almost nothing by a narrow room.
## Colour
The veil is cool and desaturated (a blue-grey), never black: a black veil
reads as a printing defect and fights the room fills. Its opacity is small —
the target is a perceptible step, not a dimmed room; the value is to be tuned
visually, in the same order as the wedge alpha today.
The light model keeps its warm ramp near the horizon (docs/SUN.md), but should
lean on **hue rather than luminance** on medium backgrounds: an amber tint
reads as colour even where it cannot read as brightness.
## What must not break
- **Room fills.** Temperature and LQI fills carry meaning; the veil must not
be mistaken for them, and must not shift their perceived colour more than
marginally. The veil goes under the fills, not over.
- **The 3° threshold and the two-second fade** (docs/SUN.md) apply unchanged
to whichever model is active. Crossing the luminance threshold — a theme
switch, day turning to night — must cross-fade between models, never pop.
- **Cloudiness** keeps dimming the effect, in the shade model by thinning the
veil.
- **Editors** stay neutral, as today: no sun, no day/night.
- **The static card and kiosk** must pick the same model as the full card on
the same plan; the choice is a pure function of the background.
- **prefers-reduced-motion**: no cross-fade, switch instantly.
- **Performance**: one veil path per room per render at most, memoised on the
same key as the wedges.
## Open questions for the owner
1. Automatic switching only, or also an explicit setting «свет / тень / авто»
in the space dialog? (Automatic is one less knob; explicit lets someone on
a light theme keep the warm wedges if they like them.)
2. Does the veil cover the whole room, or only the part of the room the sun
could reach at all (the room's window-facing side)? Whole-room is simpler
and reads as "this room is in shade"; partial is subtler.
3. Should the window mark be always on, or follow the same «Solar rays»
toggle?
## Testing notes (for when this is built)
Unit: the model chooser is a pure function of a colour → assert the switch at
the threshold, both sides, plus junk input. The veil-with-holes geometry:
holes equal the wedges, an unlit room gets a plain veil, a room with no
exterior window gets nothing.
Browser: on a white paper plan a lit wedge is measurably lighter than its
surroundings (sample pixels inside and outside the wedge — the current
implementation would fail this, which is the point); on a dark scene nothing
changes versus today; crossing the threshold cross-fades; room fills keep
their identity; the window mark appears exactly on lit windows.
Full contract and the tuned peak opacity: **docs/SUN.md, «The rim»**.
Implementation: `rayRimEdges()`, `rimStops()`, `rimPeakAlpha()` and
`RIM_MAX_ALPHA` in `src/sun.ts`, plus the `hp-sunrim-N` gradient in
`src/houseplan-card.ts`. Browser proof, including the pixel probe showing
the wedge's side is measurably darker than the paper beside it:
`demo/smoke_sun_rim.mjs`.
+56 -2
View File
@@ -219,6 +219,48 @@ left to draw an edge with.
Clipping by the room is unchanged; only the visible edge changed.
### The rim — a hairline along the sides (owner 2026-08-04)
The fill above is honest and nearly invisible on a light plan. Painting
light means ADDING luminance, and white paper has none left to give:
raising `RAY_MAX_ALPHA` does not buy contrast, it only tints the room
beige. That analysis is kept in docs/SUN-CONTRAST.md, whose «shade
instead of light» answer the owner **rejected** on 2026-08-04 in favour
of its cheap half, verbatim: «тонкая (1px) чёрная граница по бокам
светящегося сектора, которая также плавно уходит в ноль вместе с самим
градиентом». Light is invisible on paper; its BOUNDARY is not.
The contract:
- **Two side edges only.** The rim runs along the two edges that leave
the ends of the glass and travel inward with the ray — `a → a+dir·len`
and `b → b+dir·len`. Never the glass edge `a-b` (that is the source,
not a boundary) and never the far edge (there is nothing left to
outline there — the fill is already at zero, see below).
- **One screen pixel at any zoom**: `stroke-width="1"` plus
`vector-effect="non-scaling-stroke"`, so the hairline is a hairline on
a phone, on a 4K kiosk and at any zoom level of the infinite canvas.
- **Black**, and it dies exactly with the fill. A second gradient
`hp-sunrim-N` is emitted next to `hp-sun-N` with **the same
`x1,y1,x2,y2`** (the wall's inward normal, `depth` long) and **the same
normalised curve** — `rimStops()` returns `rayStops()` by identity, not
by copy, so the two can never drift apart. Only the colour and the peak
differ: `RIM_MAX_ALPHA` = 0.42 at the glass, tuned on the demo rig
against both extremes (below ~0.3 the line vanishes on paper at kiosk
scale, above ~0.5 it reads as an ink contour over the dark glow
canvas). Zero from `RAY_FADE_END` = 85 % on, like the fill.
- **Clipped by the room like the wedge**, and for free: `rayRimEdges()`
cuts the sides out of the ALREADY clipped polygons — a boundary segment
belongs to a side iff both of its ends lie on that side's line —
merging collinear pieces so an unclipped wedge yields exactly two
lines. No `clip-path` enters the sun layer, and light still cannot
cross a wall.
- **The same life as the wedge.** The rim lives inside the same
`<g class="sunlayer">`, so the 3° threshold, the 2 s layer fade,
`prefers-reduced-motion`, cloud cover (`rimPeakAlpha(cloud)`, zero in
the rain), night, the editors and the memo key all apply to it without
a line of extra logic.
### The 3° threshold and the 2-second fade
Wedge opacity does NOT depend on elevation any more — the old ramp-in
@@ -284,8 +326,8 @@ Backend validation: string or null.
## Files
- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
quads + clipping, cloud factor, settings inheritance); unit-tested
in `test/sun.test.mjs`.
quads + clipping, the rim edges and its stops, cloud factor, settings
inheritance); unit-tested in `test/sun.test.mjs`.
- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
stage background, both settings dialogs (compass dial included).
- `src/space-render.ts` — the static card's background only.
@@ -301,8 +343,20 @@ Backend validation: string or null.
DEV-EB173-01 grazing repro end to end (west window 80, elevation 90,
azimuth 190): equal sides of the nominal length, peak alpha at BOTH
ends of the glass, and no wedge at all below `RAY_MIN_COS`.
- `demo/smoke_sun_rim.mjs` — the rim: exactly two lines per wedge, on the
two SIDE edges (their coordinates checked against the wedge's own
vertices), `url(#hp-sunrim-N)` with BLACK stops on the same axis and
the same offsets as the fill, monotone and dead at 85 %,
`vector-effect: non-scaling-stroke` at width 1, gone below 3°, in an
editor and in the rain — and a pixel probe on WHITE paper proving the
side of the wedge is measurably darker than the paper on either hand
(the point of the whole change).
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
`hass` tick with no reload, asserted on the COMPUTED background of the
stage; small steps still glide, big ones catch up at once.
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
- `demo/shot_sun_rim.mjs` — the rim on white paper and on the dark glow
canvas, the same frame at several rim peaks (0 = before), which is how
`RIM_MAX_ALPHA` was chosen: `node demo/shot_sun_rim.mjs <outdir>
[0,0.3,0.42,0.5]`.
+101
View File
@@ -14,6 +14,22 @@
> nothing (external audit T1/T3). If you add a checklist line marked `[manual]`,
> add the failing check in the same commit.
> **⚠ Rule: a new scrollable list inside a dialog is tested by GEOMETRY, never
> by the DOM.** Any new scrolling box or `overflow` container added to a dialog
> MUST get a smoke that measures **the container's own height and the visible
> position of its first item** (`getBoundingClientRect`, and the item's rect
> against the box's rect) — counting rendered rows, or asserting that the nodes
> exist, proves nothing. The failure mode is always the same and always
> invisible to a DOM check: a scrolling box is a flex item whose automatic
> minimum size is zero (`min-height: auto` → 0 for an `overflow` child), and a
> dialog body is a flex column with a height cap, so the box is the one child
> that can be squeezed to a sliver while every row inside it renders happily.
> It has bitten us twice already: the **target search results** in the tap
> action dialog (v1.53.1 — 26 matching automations rendered into a 1 px
> stripe; the smoke counted rows and passed) and the **«Already uploaded»**
> plan picker (dev, unreleased — rows present, box 14 px tall, same story).
> Both smokes measure heights now; write the third one that way from the start.
- [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named
facts via `check`/`checkAll` and exits non-zero on any mismatch or
uncaught in-card exception; the suite runs in CI against a FRESHLY built
@@ -1204,3 +1220,88 @@ 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]
+133
View File
@@ -0,0 +1,133 @@
# Тёплый ре-маунт: возврат на вкладку без «перезагрузки»
> Статус: реализовано (dev, DEV-B703-01…03). Код: `src/houseplan-card.ts`,
> модульная памятка `warmBoot`. Смоки: `demo/smoke_warm_remount.mjs`,
> `demo/smoke_warm_dialogs.mjs`.
## 1. Что вообще происходит
Lovelace **пересоздаёт элемент карточки** — выбрасывает старый DOM-элемент и
создаёт новый — когда websocket переподключается после долго свёрнутой
вкладки, при перестройке дашборда и при переключении видов. Страница при этом
никуда не девается, но экземпляр карточки умирает, и вместе с ним умирает
**всё, что жило в полях экземпляра**.
Для владельца это выглядит как «карточка перезагрузилась»:
* мигал прелоадер (закрыто DEV-B703-01);
* «чуть-чуть дёргается масштаб» (DEV-B703-03, §2);
* **исчезают открытые диалоговые окна** (DEV-B703-03, §3) — это и есть прямое
доказательство пересоздания: состояние диалога больше нигде не хранится.
Лекарство одно и то же: **модульная памятка** `warmBoot` — обычный `Map`,
который живёт в загруженном JS-модуле, а не в экземпляре. Ключ:
```
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config)}`
```
Смена размера окна → промах → полный защитный бут (единственный случай, когда
хром HA может реально пересобраться заново). Одинаковый конфиг дважды на одной
странице неразличим — это задокументированное ограничение (высоты у них всё
равно совпадают).
## 2. Рывок масштаба: фактическая причина
Памятка хранила только `hdrH`/`stageH`. Этого мало, потому что:
1. **Пан (`_view`) не переживал экземпляр вообще.** Единственным, что
восстанавливалось, был зум — из `localStorage` (`LS_ZOOM`, per space).
Новый экземпляр приходил с `_view = null`, `updated()` → `_refitView()`, а
затем `_loadFromServer()` звал `_restoreZoom()`, который **центрирует план**
(`_applyView(z, центр vb)`). Вид, отъеханный в угол, возвращался в центр.
Измерено смоком: до пересоздания `view.x=50, y=493`, после — `x=250, y=293`
при zoom 2.2.
2. **Зум редактора не сохраняется намеренно** (`_saveZoom()` выходит при
`_mode !== 'view'`: рабочий зум редактора — инструмент, а не «как я хочу
смотреть»), **а режим редактора сохраняется** (`LS_NAV`). Значит ре-маунт
внутри редактора возвращался в тот же редактор, но на зуме ПРОСМОТРА:
измерено 3.0 → 1.0.
3. `_viewModeSnap` (виджет «куда вернуться при выходе из редактора») умирал
вместе с экземпляром — выход из редактора после ре-маунта прыгал второй раз.
4. `_showFar` («показать дальние объекты») меняет `_baseVb()`, то есть саму
систему координат, против которой клампится вид.
**Правило теперь:** памятка хранит не «зум», а **весь вьюпорт**:
пространство, режим, зум, сам прямоугольник `_view`, `_viewModeSnap`,
`_showFar`, а также инструмент редактора (`_tool`, `_decorTool`), выделение
(`_selId`, `_rszSel`, `_decorSel`) и локальный переключатель «показать
скрытые» (`_showHidden`). Восстановление даёт **бит-в-бит тот же прямоугольник**,
а не «тот же зум». `_loadFromServer()` в этом случае **не зовёт**
`_restoreZoom()` — центрирующее восстановление осталось только для настоящей
навигации (хеш/`LS_NAV` привели на другое пространство).
Диплинк `#space=<id>` — явная навигация и по-прежнему сильнее памятки.
Памятка обновляется **на каждом `updated()`** (метод `_warmSnapshot()`), то
есть всегда отражает последний ОТРИСОВАННЫЙ кадр. Родиться запись может только
из осевшей геометрии (`_bootSettled`), поэтому холодный бут по-прежнему платит
полный защитный цикл.
## 3. Диалоги переживают пересоздание
В той же записи памятки лежит `dlg`: `{ kind, space, mode, data }`, где
`data` — **живой объект-черновик**. Памятка — состояние модуля, её никто не
сериализует, поэтому наполовину заполненный диалог устройства вместе с
загруженными PDF переезжает бесплатно и без потерь.
Восстановление (`_warmReviveDialog`) происходит **не в `setConfig`**, а на
следующий такт после `connectedCallback`: Lovelace в момент `setConfig` может
ещё держать старый элемент, а живому владельцу диалог красть нельзя.
Условия воскрешения — все обязательны:
1. запись памятки есть (тёплый ре-маунт, а не холодный старт);
2. `dlg` не пуст — то есть в **последнем отрисованном кадре** прошлого
экземпляра диалог был открыт;
3. прошлый экземпляр **отсоединился** (`freed`), и с этого момента прошло не
больше `WARM_REVIVE_MS` (10 c). Перестройка Lovelace укладывается в один
такт; ушедший на другой дашборд и вернувшийся через полчаса пользователь не
должен встречать диалог, о котором он давно забыл;
4. **то же пространство и тот же режим** (`d.space`/`d.mode`);
5. **одноразово**: `dlg` съедается в момент попытки восстановления. Третий
экземпляр диалог уже не увидит, зомби невозможен.
Осознанное закрытие (Esc / Отмена / Сохранить) чистить памятку отдельно **не
нужно**: следующий же `updated()` запишет `dlg: null`. Это сильнее ручной
чистки — ни один путь закрытия нельзя забыть.
### 3.1. Что восстанавливается с черновиком
`_spaceDialog` (настройки/создание пространства), `_markerDialog` (устройство),
`_settingsDialog` (общие настройки), `_rulesDialog` (правила иконок),
`_openingDialog` (проём), `_decorTextDialog` (надпись), `_roomDialog` вместе со
всей своей обвязкой (`_roomEditId`, `_roomFill`, источники температуры и
влажности, масштабы подписей, `_areaSel`/`_nameSel`, `_pendingSplit`, `_path`).
Информационные попапы — `_infoCard` (карточка устройства) и `_openingInfo` —
восстанавливаются **по id**, а не по объекту: конфиг мог перезагрузиться под
нами, и карточка, отрисованная из устаревшего объекта, была бы враньём. Если
объекта с таким id больше нет — попап просто не открывается.
### 3.2. Что НЕ восстанавливается — и почему
| Не воскрешаем | Причина |
|---|---|
| «**Выровнять всё по сетке**» (`_alignDialog`) | Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из шестерёнки. |
| Подтверждение **объединения комнат** (`_mergeDialog`) | Тот же класс: подтверждение необратимой правки геометрии. |
| Подтверждение действия по тапу (`_tapConfirm`) | Держит замыкание `exec` на **мёртвый** экземпляр. |
| Мастер импорта этажей (`_importDialog`) | `updated()` сам открывает его, пока конфиг пуст; воскрешение удвоило бы очередь. |
| Любой диалог с `busy: true` | Запись/загрузка была в полёте. Новый экземпляр не знает, доехала ли она; показать «Сохранить» ещё раз — это приглашение записать дважды. Правду покажет перезагрузка конфига. |
| Киоск | У киоска памятки нет вообще (`100dvh`, ничего не оседает, редактирование недоступно). |
Правило одной строкой: **воскрешаем черновик, не воскрешаем решение.**
## 4. Что осознанно отложено
Ре-маунт по-прежнему теряет мелкое промежуточное состояние жестов, у которого
нет осмысленного «продолжения» после смены экземпляра: незавершённый контур
рисования вне диалога комнаты (`_path`/`_cursorPt`), стек отмены ресайза
(`_rszUndo`), выбор разреза (`_splitSel`, `_mergeSel`), черновик фигуры декора
(`_decorDraft`), незавершённое перетаскивание подложки (`_bdDrag`), тост
(`_toast`). Все они живут внутри одного жеста; пересоздание элемента жест и так
прерывает.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.58.0",
"version": "1.59.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.58.0",
"version": "1.59.0-beta.1",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.58.0",
"version": "1.59.0-beta.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+280 -10
View File
@@ -34,6 +34,7 @@ import {
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
sunStateOf, cloudFactor, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
rayStops, skyElevation, skyNeedsSnap,
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR,
} from './sun';
import { ContentSigner } from './signing';
import { mdiHomeCityOutline } from '@mdi/js';
@@ -61,7 +62,7 @@ import {
import { alignAllToGrid, type AlignReport } from './align-grid';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.58.0';
const CARD_VERSION = '1.59.0-beta.1';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -89,9 +90,56 @@ const BOOT_SOFT_MS = 1500;
* the key keeps two DIFFERENT cards on one page from adopting each other's
* header height (same config twice on one view is indistinguishable — and
* then the heights match anyway). */
const warmBoot = new Map<string, { hdrH: number; stageH: number }>();
/** DEV-B703-03 — what the dead instance was LOOKING at. The header height
* alone was not enough: `_view` (the pan) never left the instance, and the
* EDITOR zoom is deliberately not persisted (`_saveZoom` is view-only) while
* the editor MODE is (LS_NAV). So a re-mount inside an editor came back at
* the view-mode zoom, and a panned view came back re-centred on the plan —
* the owner's «чуть-чуть дёргается масштаб». The memo now carries the whole
* viewport, so the restore is the same rect, not the same zoom number. */
type WarmViewport = {
space: string;
mode: 'view' | 'plan' | 'devices' | 'decor';
zoom: number;
view: { x: number; y: number; w: number; h: number } | null;
/** the view-mode viewport an editor was entered from (_viewModeSnap) */
snap: { space: string; zoom: number; cx?: number; cy?: number } | null;
tool: MarkupTool;
decorTool: 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text' | 'erase';
showHidden: boolean;
/** «показать дальние» changes _baseVb, so the restored view rect is only
* the same rect if the frame it was clamped against is the same one */
showFar: boolean;
selId: string | null;
rszSel: string | null;
decorSel: string | null;
};
/** Which dialog was open and its draft (docs/WARM-REMOUNT.md §3). `data` is
* the live draft OBJECT — the memo is module state, never serialised, so a
* half-filled device dialog with its uploaded pdfs survives for free. */
type WarmDialogKind = 'space' | 'marker' | 'settings' | 'opening' | 'decorText' | 'rules' | 'room' | 'info' | 'openingInfo';
type WarmDialog = { kind: WarmDialogKind; space: string; mode: string; data: any };
type WarmEntry = {
hdrH: number;
stageH: number;
vp: WarmViewport | null;
dlg: WarmDialog | null;
/** when the instance that wrote `dlg` detached; 0 = it is still alive */
freed: number;
};
const warmBoot = new Map<string, WarmEntry>();
const warmBootKey = (config: unknown): string =>
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config ?? {})}`;
/** A dialog is revived only if the instance that owned it died THIS long ago.
* A Lovelace rebuild detaches and re-attaches within one task; a user who
* walked off to another dashboard view and came back later must not be met
* by a dialog they have long forgotten opening. */
const WARM_REVIVE_MS = 10000;
/** The memo gains a key on every window RESIZE and never loses one; the
* values used to be two numbers, and now they can hold a dialog draft (a
* plan pdf among it). Keep the last few viewports — a stale entry only
* costs the next card at that size a cold boot. */
const WARM_MAX_KEYS = 8;
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -424,7 +472,7 @@ class HouseplanCard extends LitElement {
busy: boolean;
} | null = null;
/** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */
private _sunRaysCache: { key: string; rays: SunRay[] } | null = null;
private _sunRaysCache: { key: string; rays: SunRay[]; rims: number[][][][] } | null = null;
/** Sun elevation (0.1°) the day/night sky is currently PAINTED with, and
* whether the next paint must jump to it instead of gliding (docs/SUN.md). */
private _skyElev: number | null = null;
@@ -517,6 +565,13 @@ class HouseplanCard extends LitElement {
busy: boolean;
} | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
/** DEV-B703-03 warm re-mount: the dead instance's viewport, and the two
* flags that keep the restore from being undone (the server load's centred
* _restoreZoom) or applied twice (the dialog revival). */
private _warmVp: WarmViewport | null = null;
private _warmVpArmed = false;
private _warmRevivePending = false;
private _warmReviveTimer?: number;
private _hashApplied = false;
private _navApplied = false; // the saved space was restored (or the user navigated)
// ---- kiosk (wall device) mode ----
@@ -669,6 +724,12 @@ class HouseplanCard extends LitElement {
// DEV-B703-02: a reattach mid-outage must keep revalidating — the retry
// timer died in disconnectedCallback
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
// DEV-B703-03: one task later the element Lovelace replaced has detached
// — only then is its open dialog ours to take over.
if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) {
this._warmRevivePending = true;
this._warmReviveTimer = window.setTimeout(() => this._warmReviveDialog(), 0);
}
}
public disconnectedCallback(): void {
@@ -711,6 +772,13 @@ class HouseplanCard extends LitElement {
this._unsubLayout = null;
}
clearTimeout(this._layoutSyncTimer);
// DEV-B703-03: the last thing this instance was showing, then the
// tombstone that lets exactly one successor adopt the open dialog.
this._warmRevivePending = false;
clearTimeout(this._warmReviveTimer);
this._warmReviveTimer = undefined;
this._warmSnapshot();
this._warmPatch({ freed: Date.now() });
super.disconnectedCallback();
}
@@ -844,6 +912,7 @@ class HouseplanCard extends LitElement {
this._booting = false;
this._bootFading = false;
this._hdrH = warm.hdrH;
this._warmVp = warm.vp; // adopted below, once the model is in hand
this._bootSoft = true; // timer armed in connectedCallback...
if (this.isConnected) { // ...unless setConfig re-runs while attached
clearTimeout(this._bootSoftTimer);
@@ -893,6 +962,171 @@ class HouseplanCard extends LitElement {
// arm it BEFORE the first view computation, so the very first paint is
// already at the user's zoom.
if (this._mode === 'view' && !this._view) this._zoom = this._zoomBySpace[this._space] || 1;
this._warmAdoptViewport(config);
}
/**
* DEV-B703-03: put back the EXACT viewport of the instance Lovelace threw
* away — same space, same editor, same zoom and the same pan rect, so the
* re-mount is bit-for-bit the view that was on screen. Runs after the LS
* snapshot has restored the model (the space must exist) and after the
* LS_NAV/default_floor guesses, which it supersedes: the memo is the newer,
* finer-grained record of the very same intent. A `#space=` deep link is an
* EXPLICIT navigation and still wins.
*/
private _warmAdoptViewport(config: CardConfig): void {
const vp = this._warmVp;
if (!vp) return;
if (this._hashApplied || !this._model.find((sp) => sp.id === vp.space)) {
this._warmVp = null; // another space is on screen — the memo is not about it
return;
}
this._space = vp.space;
this._navApplied = true;
// the editor comes back only where an editor is allowed at all
this._mode = vp.mode !== 'view' && this._canEdit && !config.kiosk ? vp.mode : 'view';
this._zoom = vp.zoom;
this._view = vp.view ? { ...vp.view } : null;
this._viewModeSnap = vp.snap ? { ...vp.snap } : null;
this._tool = vp.tool;
this._decorTool = vp.decorTool;
this._showHidden = vp.showHidden;
if (this._showFar !== vp.showFar) { this._showFar = vp.showFar; this._frame = null; }
this._selId = vp.selId;
this._rszSel = vp.rszSel;
this._decorSel = vp.decorSel;
this._warmVpArmed = true; // _loadFromServer must not re-centre this
}
/** Merge a patch into this card's warm entry. `create` is false everywhere
* but the boot settle: a memo may only be BORN from a settled geometry. */
private _warmPatch(patch: Partial<WarmEntry>, create = false): void {
if (this._config?.kiosk) return;
const k = warmBootKey(this._config);
const cur = warmBoot.get(k);
if (!cur && !create) return;
warmBoot.set(k, { hdrH: this._hdrH, stageH: 0, vp: null, dlg: null, freed: 0, ...(cur || {}), ...patch });
while (warmBoot.size > WARM_MAX_KEYS) {
const oldest = warmBoot.keys().next().value; // Map keeps insertion order
if (oldest === undefined || oldest === k) break;
warmBoot.delete(oldest);
}
}
private _warmViewportState(): WarmViewport {
return {
space: this._space,
mode: this._mode,
zoom: this._zoom,
view: this._view ? { ...this._view } : null,
snap: this._viewModeSnap ? { ...this._viewModeSnap } : null,
tool: this._tool,
decorTool: this._decorTool,
showHidden: this._showHidden,
showFar: this._showFar,
selId: this._selId,
rszSel: this._rszSel,
decorSel: this._decorSel,
};
}
/**
* The open dialog, or null. Deliberately null (docs/WARM-REMOUNT.md §4) for:
* • «Выровнять всё по сетке» and the room MERGE confirmation — a modal whose
* only content is "press OK to rewrite your plan". Resurrecting a
* confirmation next to a user who has just come back to the tab is how a
* destructive write gets a blind click; both are one click to reopen.
* • a tap confirmation — it carries a closure over the DEAD instance.
* • the floor-import wizard — `updated()` reopens it by itself while the
* config is still empty; reviving it too would double the queue.
* • ANY dialog with a save/upload in flight (`busy`) — the new instance
* cannot know whether the write landed, and offering Save again invites a
* second one. The config reload shows the truth instead.
* Precedence follows the Esc stack: the topmost dialog is the one that is
* "open" as far as the user is concerned.
*/
private _warmDialogState(): WarmDialog | null {
const at = (kind: WarmDialogKind, data: any): WarmDialog =>
({ kind, space: this._space, mode: this._mode, data });
if (this._tapConfirm || this._alignDialog || this._mergeDialog || this._importDialog) return null;
if (this._openingInfo) return at('openingInfo', (this._openingInfo as any).id);
if (this._infoCard) return at('info', this._infoCard.id);
if (this._rulesDialog) return this._rulesDialog.busy ? null : at('rules', this._rulesDialog);
if (this._settingsDialog) return this._settingsDialog.busy ? null : at('settings', this._settingsDialog);
if (this._markerDialog) return this._markerDialog.busy ? null : at('marker', this._markerDialog);
if (this._openingDialog) return at('opening', this._openingDialog);
if (this._decorTextDialog) return at('decorText', this._decorTextDialog);
if (this._roomDialog) {
return at('room', {
editId: this._roomEditId, fill: this._roomFill, tempSrc: this._roomTempSrc,
humSrc: this._roomHumSrc, srcOpen: this._roomSrcOpen, srcFilter: this._roomSrcFilter,
nameScale: this._roomNameScale, labelScale: this._roomLabelScale,
areaSel: this._areaSel, nameSel: this._nameSel,
pendingSplit: this._pendingSplit, path: this._path,
});
}
if (this._spaceDialog) return this._spaceDialog.busy ? null : at('space', this._spaceDialog);
return null;
}
/** Mirror the live viewport (and the open dialog) into the memo. Called from
* every `updated()`: whatever the previous instance last PAINTED is what the
* next one must open at — and a dialog closed with Esc/Cancel/Save writes
* `dlg: null` here on the very next render, so it can never come back. */
private _warmSnapshot(): void {
if (this._booting || this._config?.kiosk) return;
const patch: Partial<WarmEntry> = { vp: this._warmViewportState() };
// do not overwrite the snapshot we are about to revive FROM
if (!this._warmRevivePending) patch.dlg = this._warmDialogState();
this._warmPatch(patch);
}
/**
* Re-open the dialog the dead instance had open. Not done in setConfig:
* Lovelace may still be holding the old element there, and a live owner's
* dialog must not be stolen. One task later the previous instance has
* detached (`freed`) and the snapshot is ours to consume — exactly once.
*/
private _warmReviveDialog(): void {
this._warmRevivePending = false;
const k = warmBootKey(this._config);
const e = warmBoot.get(k);
if (!e || !e.dlg) return;
const d = e.dlg;
warmBoot.set(k, { ...e, dlg: null, freed: 0 }); // consume-once: no zombie on the third mount
if (!e.freed || Date.now() - e.freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago
if (d.space !== this._space || d.mode !== this._mode) return; // never in another space/editor
switch (d.kind) {
case 'space': this._spaceDialog = { ...d.data, busy: false, savedBusy: false }; break;
case 'marker': this._markerDialog = { ...d.data, busy: false }; break;
case 'settings': this._settingsDialog = { ...d.data, busy: false }; break;
case 'rules': this._rulesDialog = { ...d.data, busy: false }; break;
case 'opening': this._openingDialog = { ...d.data }; break;
case 'decorText': this._decorTextDialog = { ...d.data }; break;
case 'room': {
const r = d.data;
this._roomEditId = r.editId; this._roomFill = r.fill; this._roomTempSrc = r.tempSrc;
this._roomHumSrc = r.humSrc; this._roomSrcOpen = r.srcOpen; this._roomSrcFilter = r.srcFilter;
this._roomNameScale = r.nameScale; this._roomLabelScale = r.labelScale;
this._areaSel = r.areaSel; this._nameSel = r.nameSel;
this._pendingSplit = r.pendingSplit; this._path = r.path;
this._roomDialog = true;
break;
}
// info popups are re-resolved by id: the config may have been reloaded
// under us, and a card rendered from a stale object is a lie
case 'info': {
const dev = this._devices.find((x) => x.id === d.data);
if (dev) this._infoCard = dev;
break;
}
case 'openingInfo': {
const op = (this._curSpaceCfg?.openings || []).find((x: any) => x.id === d.data);
if (op) this._openingInfo = op;
break;
}
}
this.requestUpdate();
}
/** Save a snapshot of the config+layout to localStorage for an instant start. */
@@ -1094,6 +1328,7 @@ class HouseplanCard extends LitElement {
protected updated(): void {
this._skyRelease();
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
const stage = this._stageEl;
if (stage && !this._roViewport) {
this._roViewport = new ResizeObserver(() => this._refitView());
@@ -1122,7 +1357,7 @@ class HouseplanCard extends LitElement {
// resize with a live card) must not poison the next warm mount —
// the memo follows the live settled geometry.
if (t >= 0 && !this._booting && !this._config?.kiosk && stage.clientHeight > 0) {
warmBoot.set(warmBootKey(this._config), { hdrH: t, stageH: stage.clientHeight });
this._warmPatch({ hdrH: t, stageH: stage.clientHeight });
}
};
// a frame later: setting state straight from the observer callback makes
@@ -1219,7 +1454,12 @@ class HouseplanCard extends LitElement {
this._space = this._model[0]?.id || this._space;
}
this._cacheSnapshot();
this._restoreZoom();
// DEV-B703-03: a warm re-mount already holds the exact viewport of the
// instance that was thrown away; the centred restore here IS the
// reported jerk. Only a genuine navigation (the hash/nav landed us on
// another space) still needs it.
if (this._warmVpArmed && this._space === this._warmVp?.space) this._warmVpArmed = false;
else this._restoreZoom();
} catch (e) {
if (this._serverCfg) {
// DEV-B703-02: this instance already RENDERS a valid config (the LS
@@ -2157,7 +2397,7 @@ class HouseplanCard extends LitElement {
// same config at the same viewport opens warm (no veil, no wait).
const settledH = this._stageEl?.clientHeight ?? 0;
if (!this._config?.kiosk && settledH > 0) {
warmBoot.set(warmBootKey(this._config), { hdrH: this._hdrH, stageH: settledH });
this._warmPatch({ hdrH: this._hdrH, stageH: settledH, vp: this._warmViewportState() }, true);
}
this._bootFading = true; // one soft opacity-out, then out of the DOM
this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220);
@@ -5308,12 +5548,17 @@ class HouseplanCard extends LitElement {
const windows = this._openingsR
.filter((o) => o.type === 'window')
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
this._sunRaysCache = { key, rays: computeSunRays(rooms, windows, sun.azimuth, sun.elevation, north!) };
const rays = computeSunRays(rooms, windows, sun.azimuth, sun.elevation, north!);
// the rim is pure geometry off the same wedges — memoised on the same key
this._sunRaysCache = { key, rays, rims: rays.map((r) => rayRimEdges(r)) };
}
const rays = this._sunRaysCache.rays;
const rims = this._sunRaysCache.rims;
if (!rays.length) return empty;
const color = rayColor(dayPhase(sun.elevation).warmth);
const stops = rayStops();
const rimAlpha = rimPeakAlpha(cloud);
const rimStopList = rimStops();
// NO filter here, and none in <defs>. Owner 2026-08-04: «не надо размывать
// их боковые грани» — the shaft keeps the crisp sides real light has, and
// the only falloff is the gradient. The tip needs no blur either:
@@ -5330,21 +5575,41 @@ class HouseplanCard extends LitElement {
// wall; with this axis every point `source + dir·u` lands at offset
// `u/len`. Whole pane at peak alpha, identical fade distance along every
// ray, and the parallelogram's far edge exactly on the gradient's end.
//
// THE RIM (owner 2026-08-04, docs/SUN.md «The rim»): a 1 px black hairline
// along the two SIDE edges only, so the shaft stays legible on white paper
// where added luminance cannot read. It gets a gradient of its own —
// `hp-sunrim-i` — deliberately built on the SAME x1/y1/x2/y2 and the same
// `rayStops()` curve as `hp-sun-i`, only black and with its own peak: the
// line must die on the very stop the fill dies on, never outlive it.
// `non-scaling-stroke` keeps it one screen pixel at any zoom, and the
// segments come out of the ALREADY clipped polygons, so a wall still stops
// the light by geometry alone and no clip-path enters this layer.
return svg`<defs>
${rays.map((r, i) => {
const mx = (r.a[0] + r.b[0]) / 2;
const my = (r.a[1] + r.b[1]) / 2;
const ax = mx + r.normal[0] * r.depth;
const ay = my + r.normal[1] * r.depth;
return svg`<linearGradient id="hp-sun-${i}" gradientUnits="userSpaceOnUse"
x1="${mx}" y1="${my}"
x2="${mx + r.normal[0] * r.depth}" y2="${my + r.normal[1] * r.depth}">
x1="${mx}" y1="${my}" x2="${ax}" y2="${ay}">
${stops.map(([off, k]) => svg`<stop offset="${(off * 100).toFixed(1)}%"
stop-color="${color}" stop-opacity="${(alpha * k).toFixed(4)}"></stop>`)}
</linearGradient>
<linearGradient id="hp-sunrim-${i}" gradientUnits="userSpaceOnUse"
x1="${mx}" y1="${my}" x2="${ax}" y2="${ay}">
${rimStopList.map(([off, k]) => svg`<stop offset="${(off * 100).toFixed(1)}%"
stop-color="${RIM_COLOR}" stop-opacity="${(rimAlpha * k).toFixed(4)}"></stop>`)}
</linearGradient>`;
})}
</defs>
<g class="sunlayer ${this._sunOut ? 'out' : ''}">
${rays.map((r, i) => r.polys.map((p) => svg`<polygon
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`))}
${rays.map((r, i) => (rims[i] || []).map((e) => svg`<line class="sunrim"
x1="${e[0][0]}" y1="${e[0][1]}" x2="${e[1][0]}" y2="${e[1][1]}"
stroke="url(#hp-sunrim-${i})" stroke-width="1"
vector-effect="non-scaling-stroke"></line>`))}
</g>` as unknown as TemplateResult;
}
@@ -6124,7 +6389,12 @@ class HouseplanCard extends LitElement {
: nothing}
</button>`,
)}
${this._norm && this._mode === 'plan'
${''/* «Добавить пространство» is a NAVIGATION action, not a plan-editor
tool (owner 2026-08-04): it lives next to the floor names in every
mode, exactly where the per-space gear does. Kiosk is a shop
window — the whole .hdr is display:none there, but the button is
also not RENDERED, so nothing invisible is clickable. */}
${this._canEdit && !this._kiosk
? html`<button class="tab tabadd" title=${this._t('title.add_space')}
@click=${() => this._openSpaceDialog('create')}>
<ha-icon icon="mdi:plus"></ha-icon>
+8
View File
@@ -1559,6 +1559,14 @@ export const cardStyles = css`
border: 1px solid var(--hp-line);
border-radius: var(--rad-m);
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
/* The same collapse that ate .candlist (v1.53.1): a scroll box is a
flex item whose automatic minimum size is ZERO (overflow != visible),
so inside .dialog .body — a flex column taller than its 66vh cap —
it shrank to a 14px sliver: the rows were in the DOM, the owner saw
a thin rounded stripe under the "Already uploaded" button. Don't
shrink, and keep a floor even when the box is empty or loading. */
flex: 0 0 auto;
min-height: 2.6em;
}
.savedplan {
display: flex;
+105
View File
@@ -359,6 +359,111 @@ export function rayStops(): [number, number][] {
];
}
// ---------------- the rim (owner 2026-08-04) ----------------
/**
* The rim: «тонкая (1px) чёрная граница по бокам светящегося сектора, которая
* также плавно уходит в ноль вместе с самим градиентом».
*
* Why at all: painting light means ADDING luminance, and white paper has none
* left to give (the analysis kept in docs/SUN-CONTRAST.md). The owner rejected
* the «shade instead of light» model that analysis proposed and asked for the
* cheap half of it instead — light is invisible on white, but its BOUNDARY is
* not. One hairline along each side of the shaft gives the wedge a "beam"
* reading on paper without touching the fill, the geometry or anything a dark
* scene already gets right.
*
* Contract (docs/SUN.md, «The rim»):
*
* - only the two SIDE edges — the ones running from the ends of the window
* along `dir`. Never the glass edge (a-b) and never the far edge: those are
* not boundaries of the beam, they are its source and its end;
* - one screen pixel at any zoom (`vector-effect: non-scaling-stroke`);
* - black, and it dies EXACTLY with the fill: same gradient axis (the wall's
* inward normal, `depth` long), same normalised curve `rayStops()`, same
* `RAY_FADE_END` — so no rim can outlive the light it outlines;
* - clipped by the room like the wedge itself, which here is free: the
* segments are cut out of the ALREADY clipped polygons (`rayRimEdges`),
* so no `clip-path` enters the sun layer (docs/SUN.md keeps that promise).
*/
/**
* Peak rim opacity at the glass, before cloud cover. Visually tuned on the
* demo rig at both extremes: it has to make the shaft legible on white paper
* (the whole point) yet not read as an ink outline over the dark glow canvas.
* Below ~0.3 the line disappears on paper at kiosk scale; above ~0.5 it turns
* into a drawn contour on a night scene.
*/
export const RIM_MAX_ALPHA = 0.42;
/** The rim is black — the one thing white paper still has room for. */
export const RIM_COLOR = '#000000';
/** Peak rim opacity for the current cloud cover — dimmed exactly like the fill. */
export function rimPeakAlpha(cloud = 1): number {
return RIM_MAX_ALPHA * clamp01(cloud);
}
/**
* Rim gradient stops — the SAME normalised curve as the fill, by identity and
* not by copy: «прозрачность гаснет ВМЕСТЕ с заливкой ... ровно по той же
* кривой и тому же порогу». Only the peak alpha and the colour differ.
*/
export function rimStops(): [number, number][] {
return rayStops();
}
/**
* The two side edges of a wedge, cut to exactly what the room left of it.
*
* The clipped polygons already contain those edges: a boundary segment belongs
* to a side iff both of its endpoints lie on that side's line (through `a`,
* resp. `b`, along `dir`). Collinear pieces — polyclip readily splits an edge
* at a touching vertex, and an L-shaped room can cut a side into several
* stretches — are projected onto `dir` and merged, so an unclipped wedge
* yields exactly two segments and a clipped one the fewest that cover it.
*
* `eps` is in render units (the canvas is NORM_W = 1000 wide), comfortably
* above polyclip's rounding and far below anything the eye could see.
*/
export function rayRimEdges(ray: SunRay, eps = 1e-4): number[][][] {
const [dx, dy] = ray.dir;
const nx = -dy;
const ny = dx;
const out: number[][][] = [];
for (const src of [ray.a, ray.b]) {
const spans: [number, number][] = [];
for (const poly of ray.polys) {
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
// off the side's line? then this boundary edge is the glass, the far
// edge, or a wall the room cut the wedge with — not a side of the beam
if (Math.abs((p[0] - src[0]) * nx + (p[1] - src[1]) * ny) > eps) continue;
if (Math.abs((q[0] - src[0]) * nx + (q[1] - src[1]) * ny) > eps) continue;
const up = (p[0] - src[0]) * dx + (p[1] - src[1]) * dy;
const uq = (q[0] - src[0]) * dx + (q[1] - src[1]) * dy;
if (Math.abs(uq - up) <= eps) continue; // degenerate sliver
spans.push(up < uq ? [up, uq] : [uq, up]);
}
}
spans.sort((s, t) => s[0] - t[0]);
const merged: [number, number][] = [];
for (const s of spans) {
const last = merged[merged.length - 1];
if (last && s[0] <= last[1] + eps) last[1] = Math.max(last[1], s[1]);
else merged.push([s[0], s[1]]);
}
for (const [u0, u1] of merged) {
out.push([
[src[0] + dx * u0, src[1] + dy * u0],
[src[0] + dx * u1, src[1] + dy * u1],
]);
}
}
return out;
}
/**
* Day/night sky: how far the painted sky may drift from the real sun before
* the card stops gliding and simply JUMPS to the right colour.
+93
View File
@@ -7,6 +7,7 @@ import {
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
RAY_LENGTH_K, RAY_FADE_END, rayStops, RAY_MIN_COS,
rimStops, rimPeakAlpha, rayRimEdges, RIM_MAX_ALPHA, RIM_COLOR,
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
} from '../test-build/sun.js';
@@ -146,6 +147,98 @@ test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
assert.ok(half[0] <= 0.65, 'past half-dark by two thirds of the way');
});
// ---- the rim (owner 2026-08-04, docs/SUN.md «The rim») -----------------
test('rimStops: the rim dies on exactly the same curve as the fill', () => {
const rim = rimStops();
// «ровно по той же кривой и тому же порогу» — identity, not a copy that can
// drift: if the fill's easing is ever retuned the outline follows it.
assert.deepEqual(rim, rayStops());
assert.ok(near(rim[0][0], 0) && near(rim[0][1], 1), 'brightest at the glass');
assert.ok(near(rim[rim.length - 1][0], 1), 'spans the FULL wedge, like the fill');
for (let i = 1; i < rim.length; i++) {
assert.ok(rim[i][0] > rim[i - 1][0] || rim[i][0] === 1, 'offsets ascend');
assert.ok(rim[i][1] <= rim[i - 1][1], 'the rim never brightens inward');
}
for (const [off, k] of rim) {
if (off >= RAY_FADE_END) assert.equal(k, 0, 'no rim at/after ' + RAY_FADE_END);
else assert.ok(k > 0, 'still drawn at ' + off);
}
// black, and visible on paper without becoming an ink contour on a dark scene
assert.equal(RIM_COLOR, '#000000');
assert.ok(RIM_MAX_ALPHA >= 0.35 && RIM_MAX_ALPHA <= 0.5, 'the owner\'s 0.35..0.5 window');
assert.ok(near(rimPeakAlpha(1), RIM_MAX_ALPHA));
assert.ok(near(rimPeakAlpha(), RIM_MAX_ALPHA)); // clear sky by default
assert.ok(near(rimPeakAlpha(0.4), RIM_MAX_ALPHA * 0.4)); // clouds dim it too
assert.equal(rimPeakAlpha(0), 0); // rain takes it with the light
assert.ok(near(rimPeakAlpha(5), RIM_MAX_ALPHA)); // garbage cloud factor clamps
});
test('rayRimEdges: the two SIDE edges only, cut exactly like the wedge', () => {
// a west window in r1, a western sun square into it — the wedge stays well
// inside the room, so both sides are whole
const [ray] = computeSunRays(ROOMS, [WIN.west], 270, 60, 0);
assert.ok(ray, 'the west window is lit');
const edges = rayRimEdges(ray);
assert.equal(edges.length, 2, 'one line per side, no more');
const far = (s) => [s[0] + ray.dir[0] * ray.len, s[1] + ray.dir[1] * ray.len];
const same = (p, q) => near(p[0], q[0], 1e-6) && near(p[1], q[1], 1e-6);
const has = (s, t) => edges.some(([p, q]) => (same(p, s) && same(q, t)) || (same(p, t) && same(q, s)));
assert.ok(has(ray.a, far(ray.a)), 'the side from a runs the full reach');
assert.ok(has(ray.b, far(ray.b)), 'the side from b runs the full reach');
// never the glass (a-b) and never the far edge: every rim segment is
// parallel to the ray, and both of them are the full length
for (const [p, q] of edges) {
const dx = q[0] - p[0];
const dy = q[1] - p[1];
const L = Math.hypot(dx, dy);
assert.ok(near(L, ray.len, 1e-6), 'a whole side, not a wall of the room');
assert.ok(near((dx / L) * ray.dir[1] - (dy / L) * ray.dir[0], 0, 1e-9), 'parallel to the ray');
}
// ...and the glass edge is NOT among them, however you orient it
assert.ok(!has(ray.a, ray.b), 'the pane of glass is not a rim');
assert.ok(!has(far(ray.a), far(ray.b)), 'the far edge is not a rim either');
});
test('rayRimEdges: a room that cuts the shaft cuts the rim with it', () => {
// the same window in a room only 30 units deep — the wedge (~46 long at 60°)
// hits the far wall, and both rims must stop on it, not carry on in mid-air
const narrow = [{ id: 'n1', poly: [[100, 100], [130, 100], [130, 500], [100, 500]] }];
const [ray] = computeSunRays(narrow, [WIN.west], 270, 60, 0);
assert.ok(ray && ray.len > 30, 'the wedge really is longer than the room');
const edges = rayRimEdges(ray);
assert.equal(edges.length, 2);
for (const [p, q] of edges) {
assert.ok(near(Math.hypot(q[0] - p[0], q[1] - p[1]), 30, 1e-6), 'clipped to the room');
assert.ok(Math.max(p[0], q[0]) <= 130 + 1e-6, 'nothing past the far wall');
}
// and the shortened rim still starts at the glass
assert.ok(edges.some(([p]) => near(p[0], 100, 1e-6) && near(p[1], 270, 1e-6)));
assert.ok(edges.some(([p]) => near(p[0], 100, 1e-6) && near(p[1], 330, 1e-6)));
});
test('rayRimEdges: collinear splinters merge, an empty wedge draws nothing', () => {
const [ray] = computeSunRays(ROOMS, [WIN.west], 270, 60, 0);
// polyclip readily splits a side at a touching vertex; the rim must still be
// ONE line per side, not a string of them
const poly = ray.polys[0];
const split = [];
for (let i = 0; i < poly.length; i++) {
const p = poly[i];
const q = poly[(i + 1) % poly.length];
split.push(p, [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]);
}
assert.equal(split.length, 8, 'every edge of the wedge is now two');
const cut = { ...ray, polys: [split] };
const merged = rayRimEdges(cut);
assert.equal(merged.length, 2, 'still one line per side, not four');
for (const [p, q] of merged) {
assert.ok(near(Math.hypot(q[0] - p[0], q[1] - p[1]), ray.len, 1e-6), 'the whole side');
}
// a wedge clipped away to nothing has no rim at all
assert.deepEqual(rayRimEdges({ ...ray, polys: [] }), []);
});
test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
assert.equal(SKY_SNAP_DEG, 3);
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet