mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
BG: opaque plan paper — the scene background never bleeds through the plan
Owner request 2026-08-03: bg_color (and the daynight sky) used to shine through the plan itself — a hand-drawn plan's translucent room fills sat directly on the scene colour, and a transparent backdrop image let it through too. An opaque rect.hp-paper now sits under everything the plan draws and hugs the plan's extents (the backdrop image rect, or the drawn content bounds the opening view fits). Its colour is the pre-bg_color canvas: white for drawn plans (.stage.noplan), the theme card background under an image and on the static space-card. The daynight night keeps dimming the plan via the zoomwrap brightness filter ONLY — the paper's alpha never changes. The scene colour is visible strictly AROUND the plan, in view/kiosk/editors and the static card alike. smoke_bg_color grew the contract (sections 11–13): paper presence, geometry and opacity in view/editors/night, the white drawn-plan paper, the static card's paper, plus a pixel proof against an acid #ff00ff background (screenshot → canvas: no acid admixture inside the plan, acid right outside it). The suite fails on the previous build. docs: SUN.md background contract + TESTING.md checklist item.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -141,4 +141,166 @@ const res = await page.evaluate(async () => {
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
|
||||
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
|
||||
// The scene bg_color must never bleed through the plan itself: an opaque
|
||||
// rect.hp-paper sits under everything the plan draws and hugs the plan's
|
||||
// extents (backdrop image rect / drawn-content bounds). The scene colour is
|
||||
// visible ONLY around it. Verified computed AND by pixels against an acid
|
||||
// background; the 'daynight' night dims the paper via brightness only.
|
||||
const paper = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#ff00ff' };
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
await upd();
|
||||
const m = c._spaceModel();
|
||||
const p = sr().querySelector('.stage svg rect.hp-paper');
|
||||
out.paperExists = !!p;
|
||||
if (p) {
|
||||
const a = (n) => Number(p.getAttribute(n));
|
||||
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
|
||||
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
|
||||
const cs = getComputedStyle(p);
|
||||
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
|
||||
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
|
||||
}
|
||||
// editors keep the paper too (their canvas ignores bg_color anyway)
|
||||
c._setMode('decor');
|
||||
await upd();
|
||||
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
|
||||
c._setMode('view');
|
||||
await upd();
|
||||
// daynight night: the paper dims via the brightness filter ONLY, never alpha
|
||||
c._serverCfg.settings.bg_mode = 'daynight';
|
||||
c._serverCfg.settings.north_deg = 0;
|
||||
c._cfgRev++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'below_horizon',
|
||||
attributes: { azimuth: 180, elevation: -20 },
|
||||
} } };
|
||||
await upd();
|
||||
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
|
||||
const pn = sr().querySelector('.stage svg rect.hp-paper');
|
||||
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
|
||||
delete c._serverCfg.settings.bg_mode;
|
||||
delete c._serverCfg.settings.north_deg;
|
||||
c._cfgRev++;
|
||||
await upd();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, paper);
|
||||
|
||||
// ---- 12) drawn plan (no backdrop image): white paper + pixel proof ------
|
||||
const drawn = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sp = c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
sp.plan_url = ''; // hand-drawn plan now
|
||||
c._cfgRev = (c._cfgRev || 0) + 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
const p = sr().querySelector('.stage.noplan svg rect.hp-paper');
|
||||
out.drawnPaperExists = !!p;
|
||||
out.drawnPaperWhite = !!p && getComputedStyle(p).fill === 'rgb(255, 255, 255)';
|
||||
if (p) {
|
||||
const pr = p.getBoundingClientRect();
|
||||
const st = sr().querySelector('.stage').getBoundingClientRect();
|
||||
out.rects = {
|
||||
paper: [pr.left, pr.top, pr.width, pr.height],
|
||||
stage: [st.left, st.top, st.width, st.height],
|
||||
};
|
||||
// the paper hugs the CONTENT — the scene colour stays visible around it
|
||||
out.bgVisibleAround = pr.left - st.left > 8 || (st.left + st.width) - (pr.left + pr.width) > 8
|
||||
|| pr.top - st.top > 8 || (st.top + st.height) - (pr.top + pr.height) > 8;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const { rects } = drawn;
|
||||
delete drawn.rects;
|
||||
Object.assign(res, drawn);
|
||||
|
||||
// pixel proof: screenshot → canvas → not a single probe inside the paper
|
||||
// carries the acid scene colour; right outside the paper it IS the acid colour
|
||||
if (rects) {
|
||||
const shot = (await page.screenshot()).toString('base64');
|
||||
const px = await page.evaluate(async ({ shot, rects }) => {
|
||||
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 at = (x, y) => [...ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data];
|
||||
const acid = ([r, g, b]) => r > 200 && b > 200 && g < 160; // #ff00ff admixture
|
||||
const [pl, pt, pw, ph] = rects.paper;
|
||||
const [sl, st, sw, sh] = rects.stage;
|
||||
const probes = [];
|
||||
for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9])
|
||||
for (const fy of [0.1, 0.3, 0.5, 0.7, 0.9]) probes.push(at(pl + pw * fx, pt + ph * fy));
|
||||
// a point in the stage but outside the paper (the widest gap side)
|
||||
const gaps = [
|
||||
[pl - sl, [(sl + pl) / 2, pt + ph / 2]],
|
||||
[sl + sw - pl - pw, [(pl + pw + sl + sw) / 2, pt + ph / 2]],
|
||||
[pt - st, [pl + pw / 2, (st + pt) / 2]],
|
||||
[st + sh - pt - ph, [pl + pw / 2, (pt + ph + st + sh) / 2]],
|
||||
].sort((a, b) => b[0] - a[0]);
|
||||
return {
|
||||
noBleedInsidePlan: probes.every((q) => !acid(q)),
|
||||
acidAroundPlan: gaps[0][0] > 8 && acid(at(gaps[0][1][0], gaps[0][1][1])),
|
||||
};
|
||||
}, { shot, rects });
|
||||
Object.assign(res, px);
|
||||
} else {
|
||||
res.noBleedInsidePlan = false;
|
||||
res.acidAroundPlan = false;
|
||||
}
|
||||
|
||||
// ---- 13) the static space-card follows the same paper contract ----------
|
||||
const stat = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const mk = async (cfg) => {
|
||||
const hass2 = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (m.type === 'houseplan/layout/get') return { layout: {} };
|
||||
return c.hass.callWS(m);
|
||||
} };
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const el = document.createElement('houseplan-space-card');
|
||||
el.setConfig({ type: 'custom:houseplan-space-card', space: c._space });
|
||||
el.hass = hass2;
|
||||
host.appendChild(el);
|
||||
const t0 = Date.now();
|
||||
while (!el.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000)
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await el.updateComplete;
|
||||
return { el, host };
|
||||
};
|
||||
const base = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
base.spaces.find((s) => s.id === c._space).plan_url = '/assets/f1.svg';
|
||||
base.settings = { ...(base.settings || {}), bg_color: '#ff00ff' };
|
||||
const a = await mk(base);
|
||||
const pa = a.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
|
||||
out.staticPaperExists = !!pa;
|
||||
out.staticPaperOpaque = !!pa && getComputedStyle(pa).fillOpacity === '1'
|
||||
&& getComputedStyle(pa).fill !== 'none';
|
||||
a.host.remove();
|
||||
const drawnCfg = JSON.parse(JSON.stringify(base));
|
||||
drawnCfg.spaces.find((s) => s.id === c._space).plan_url = '';
|
||||
const b = await mk(drawnCfg);
|
||||
const pb = b.el.renderRoot.querySelector('.hp-static-stage svg rect.hp-paper');
|
||||
out.staticDrawnPaperExists = !!pb;
|
||||
b.host.remove();
|
||||
return out;
|
||||
});
|
||||
Object.assign(res, stat);
|
||||
checkAll({ ...paper, ...drawn, ...stat, noBleedInsidePlan: res.noBleedInsidePlan, acidAroundPlan: res.acidAroundPlan });
|
||||
await finish(browser, res);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+50
-31
File diff suppressed because one or more lines are too long
+11
@@ -61,6 +61,17 @@ only — no entities are created, no services are called.
|
||||
- Backend validation: `In(['static', 'daynight'])` at both levels.
|
||||
- `'daynight'` follows the general gate: without `north_deg` (or
|
||||
without `sun.sun`) it behaves as `'static'`.
|
||||
- **The scene background never bleeds through the plan** (owner,
|
||||
2026-08-03). In BOTH modes the background — `bg_color` or the
|
||||
daynight sky — is visible only AROUND the plan: an opaque
|
||||
`rect.hp-paper` sits under everything the plan draws and hugs the
|
||||
plan's extents (the backdrop image rect, or the drawn content bounds
|
||||
the opening view fits). Its colour is the pre-bg_color canvas —
|
||||
white for hand-drawn plans, the theme card background under an
|
||||
image. The night dimming above is the `brightness` filter on the
|
||||
zoomwrap ONLY; the paper's alpha never changes. Applies to
|
||||
view/kiosk/editors and the static space-card alike
|
||||
(smoke_bg_color).
|
||||
|
||||
## Window light wedges — `settings.sun_rays`
|
||||
|
||||
|
||||
@@ -879,6 +879,16 @@ require hands on real hardware — they remain for the human pass.
|
||||
and behaviour byte-for-byte; `daynight` hides the picker and the stage
|
||||
follows the sun — neutral day, warm golden hour, dark night — with a
|
||||
slow (tens of seconds) transition; the PLAN dims only ~10% at night
|
||||
- [ ] Opaque plan paper (2026-08-03, owner): the scene background —
|
||||
`bg_color` or the daynight sky — is visible ONLY around the plan and
|
||||
NEVER bleeds through it, in view/kiosk/editors and on the static
|
||||
space-card. An opaque `rect.hp-paper` under everything the plan draws
|
||||
hugs the plan's extents (the backdrop image rect, or the drawn
|
||||
content bounds the opening view fits); a hand-drawn plan keeps its
|
||||
historical white canvas, an image plan the theme card background.
|
||||
At night (`daynight`) the paper dims via the zoomwrap `brightness`
|
||||
filter only — its alpha stays 1. Pixel-proofed against an acid
|
||||
`#ff00ff` background [auto: smoke_bg_color]
|
||||
- [ ] Per-space overrides (background mode, north, sun-in-windows) inherit
|
||||
when empty, exactly like show_lqi/fill_mode
|
||||
- [ ] «Sunlight through windows» (default OFF): wedges appear only from
|
||||
|
||||
@@ -1596,6 +1596,23 @@ class HouseplanCard extends LitElement {
|
||||
return b ? [b.x, b.y, b.w, b.h] : m.vb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque plan "paper" (owner 2026-08-03): the scene background — bg_color or
|
||||
* the 'daynight' sky — must never bleed through the plan itself, so an
|
||||
* opaque rect sits under everything the plan draws. It hugs the plan's
|
||||
* extents: the backdrop image when there is one, otherwise the same content
|
||||
* bounds the opening view fits (an empty drawn space papers its whole
|
||||
* canvas — exactly the pre-bg_color look). Its colour is the pre-bg_color
|
||||
* canvas (styles.ts .hp-paper): white for drawn plans, the theme card
|
||||
* background under an image. At night 'daynight' dims it via the zoomwrap
|
||||
* brightness filter ONLY — its alpha stays 1 (docs/SUN.md).
|
||||
*/
|
||||
private _paperRect(m: SpaceModel): { x: number; y: number; w: number; h: number } {
|
||||
if (m.bg) return { x: m.bg.x, y: m.bg.y, w: m.bg.w, h: m.bg.h };
|
||||
const b = this._baseVb();
|
||||
return { x: b[0], y: b[1], w: b[2], h: b[3] };
|
||||
}
|
||||
|
||||
/** Aspect ratio of the scene (width/height, px). */
|
||||
private _stageAspect(): number {
|
||||
const s = this._stageEl;
|
||||
@@ -5047,6 +5064,10 @@ class HouseplanCard extends LitElement {
|
||||
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
|
||||
style="${dayNight ? `filter:brightness(${(1 - planDim).toFixed(3)})` : ''}">
|
||||
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
|
||||
${(() => {
|
||||
const pp = this._paperRect(space);
|
||||
return svg`<rect class="hp-paper" x="${pp.x}" y="${pp.y}" width="${pp.w}" height="${pp.h}" pointer-events="none"></rect>`;
|
||||
})()}
|
||||
${this._editing ? this._renderMarkupDefs(vb) : nothing}
|
||||
${this._editing && !this._markup
|
||||
? svg`<rect x="${vb[0]}" y="${vb[1]}" width="${vb[2]}" height="${vb[3]}" fill="url(#hp-grid)" pointer-events="none"></rect>`
|
||||
|
||||
@@ -205,6 +205,12 @@ class HouseplanSpaceCard extends LitElement {
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
/* Opaque plan paper — the scene bg_color/daynight sky shows only AROUND
|
||||
the plan (owner 2026-08-03). The static card keeps its historical
|
||||
canvas colour: the theme card background. */
|
||||
.hp-paper {
|
||||
fill: var(--ha-card-background, var(--card-background-color, #111));
|
||||
}
|
||||
.hp-static-stage .devlayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
+14
-1
@@ -14,7 +14,7 @@ import { t, type Lang } from './i18n';
|
||||
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
|
||||
import type { ServerConfig } from './types';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, type Layout,
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, contentBounds, type Layout,
|
||||
} from './space-geometry';
|
||||
|
||||
export { spaceModels } from './space-geometry';
|
||||
@@ -158,9 +158,22 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
}
|
||||
const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
|
||||
|
||||
// Opaque plan paper (owner 2026-08-03), same contract as the full card: the
|
||||
// scene background never bleeds through the plan. The rect hugs the backdrop
|
||||
// image, or the drawn content's bounds (marker positions included, as on the
|
||||
// full card); an empty space papers its whole canvas.
|
||||
const paperPts = spaceDevs.map((d) => {
|
||||
const p = markerPos(d, o.layout, o.cfg, defPos, space);
|
||||
return [p.x, p.y] as const;
|
||||
});
|
||||
const paper = space.bg
|
||||
? { x: space.bg.x, y: space.bg.y, w: space.bg.w, h: space.bg.h }
|
||||
: contentBounds(space, 0.05, paperPts) || { x: vb[0], y: vb[1], w: vb[2], h: vb[3] };
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
|
||||
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
|
||||
${svg`<rect class="hp-paper" x="${paper.x}" y="${paper.y}" width="${paper.w}" height="${paper.h}"></rect>`}
|
||||
${bgHref
|
||||
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
|
||||
@@ -313,6 +313,17 @@ export const cardStyles = css`
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
/* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky
|
||||
shows ONLY around the plan, never through it. The colour is the
|
||||
pre-bg_color canvas — the theme card background under an image plan,
|
||||
plain white for a hand-drawn one (.stage.noplan). Night dimming comes
|
||||
from the .zoomwrap brightness filter, never from alpha (docs/SUN.md). */
|
||||
.hp-paper {
|
||||
fill: var(--ha-card-background, var(--card-background-color, #111));
|
||||
}
|
||||
.stage.noplan .hp-paper {
|
||||
fill: #ffffff;
|
||||
}
|
||||
.room {
|
||||
transition: 0.12s;
|
||||
cursor: default; /* v1.40.1: rooms are not clickable — the label's link icon is */
|
||||
|
||||
Reference in New Issue
Block a user