Compare commits

...
Author SHA1 Message Date
claude[bot] cb181ba5ee docs: review document for #231
Issue: #231
User-Visible: no
2026-08-22 15:57:59 +00:00
Sergey Matyunin 6d31553a74 docs: record complete decor golden impact
Issue: #231
User-Visible: no
2026-08-22 18:52:55 +03:00
claude[bot] d46abe51fc docs: review document for #231
Issue: #231
User-Visible: no
2026-08-22 15:51:52 +00:00
Sergey Matyunin b74731698f fix: render decor above room fills
Issue: #231
User-Visible: yes
2026-08-22 18:37:50 +03:00
21 changed files with 857 additions and 38 deletions
File diff suppressed because one or more lines are too long
+12
View File
@@ -215,6 +215,18 @@ export function prepareGoldenFixture(scenario) {
}
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
}
if (scenario.decorOverride) {
const space = requireSpace();
const known = new Set();
for (const shape of scenario.decorOverride) {
if (!shape?.id || known.has(shape.id))
throw new Error(`golden decorOverride has missing/duplicate id: ${shape?.id || '<empty>'}`);
if (!['line', 'rect', 'ellipse', 'text', 'furniture'].includes(shape.kind))
throw new Error(`golden decorOverride has unknown kind: ${shape.kind}`);
known.add(shape.id);
}
space.decor = structuredClone(scenario.decorOverride);
}
if (scenario.openingGeometry) {
const space = requireSpace();
const opening = (space.openings || []).find(
+35 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 34;
export const GOLDEN_MATRIX_VERSION = 35;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -11,6 +11,29 @@ const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, max
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
const decorLayerFixture = [
{ id: 'golden-decor-axis', kind: 'line', x1: 0.10, y1: 0.54, x2: 0.90, y2: 0.54,
color: '#ff0033', opacity: 1, width_cm: 8 },
{ id: 'golden-decor-rect', kind: 'rect', x: 0.15, y: 0.28, w: 0.16, h: 0.12,
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
fill_color: '#ff6680', fill_opacity: 1 },
{ id: 'golden-decor-ellipse', kind: 'ellipse', x: 0.69, y: 0.28, w: 0.16, h: 0.12,
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
fill_color: '#ff6680', fill_opacity: 1 },
{ id: 'golden-decor-text', kind: 'text', x: 0.24, y: 0.72, text: 'DECOR',
color: '#ff0033', opacity: 1, size_cm: 14 },
{ id: 'golden-decor-sofa', kind: 'furniture', symbol: 'sofa',
x: 0.68, y: 0.66, w: 0.18, h: 0.13, color: '#ff0033', opacity: 1, width_cm: 2 },
];
const decorLayerProbes = {
color: '#ff0033', radius: 2, minMatchingFraction: 0.6,
points: [
{ id: 'left-room', x: 0.25, y: 0.54 },
{ id: 'opening-tunnel', x: 0.50, y: 0.54 },
{ id: 'right-room', x: 0.75, y: 0.54 },
],
};
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
@@ -167,6 +190,17 @@ export const GOLDEN_SCENARIOS = Object.freeze([
}],
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3, dpr2: true },
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'decor-over-opaque-hover-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'custom', customFill: { c: '#2255cc', a: 1 }, glowEnabled: false,
sunRays: false, showBorders: false, showNames: false, hideOpenings: true,
hoverRoom: 'light-left', decorOverride: decorLayerFixture,
decorPixelProbes: decorLayerProbes,
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'decor-over-glow-base-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'glow', glowEnabled: true, allLightsOff: true,
sunRays: false, showBorders: false, showNames: false, hideOpenings: true,
decorOverride: decorLayerFixture, decorPixelProbes: decorLayerProbes,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
fillMode: 'none', glowEnabled: false, hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
+67
View File
@@ -302,6 +302,61 @@ async function countWarmPixels(page, png, region) {
}, { png64: png.toString('base64'), region });
}
/** Semantic guard for #231. Sample named plan points from the actual browser
* capture; the decor node merely existing in DOM is insufficient when an
* opaque floor layer is painted after it. */
async function inspectDecorPixels(page, png, clip, spec) {
return page.evaluate(async ({ png64, clip, spec }) => {
const card = window.__goldenCard;
const svg = card?.renderRoot?.querySelector('.stage .zoomwrap > svg');
const decor = card?.renderRoot?.querySelector('.decorlayer');
const matrix = svg?.getScreenCTM?.();
if (!svg || !decor || !matrix) throw new Error('semantic golden decor layer is missing');
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = image.width; canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, image.width, image.height).data;
const cssWidth = clip?.width || document.documentElement.clientWidth;
const cssHeight = clip?.height || document.documentElement.clientHeight;
const scaleX = image.width / Math.max(1, cssWidth);
const scaleY = image.height / Math.max(1, cssHeight);
const originX = clip?.x || 0, originY = clip?.y || 0;
const expected = String(spec.color || '').match(/^#([0-9a-f]{6})$/i)?.[1];
if (!expected) throw new Error(`semantic golden decor color is invalid: ${spec.color}`);
const rgb = [0, 2, 4].map((offset) => parseInt(expected.slice(offset, offset + 2), 16));
const radius = Math.max(0, Math.round(spec.radius || 0));
return spec.points.map((probe) => {
const point = svg.createSVGPoint();
point.x = Number(probe.x) * 1000; point.y = Number(probe.y) * 1000;
const screen = point.matrixTransform(matrix);
const cx = Math.round((screen.x - originX) * scaleX);
const cy = Math.round((screen.y - originY) * scaleY);
let matching = 0, sampled = 0;
for (let y = cy - radius; y <= cy + radius; y++) {
for (let x = cx - radius; x <= cx + radius; x++) {
if (x < 0 || y < 0 || x >= image.width || y >= image.height) continue;
const offset = (y * image.width + x) * 4;
const distance = Math.max(
Math.abs(pixels[offset] - rgb[0]),
Math.abs(pixels[offset + 1] - rgb[1]),
Math.abs(pixels[offset + 2] - rgb[2]),
);
if (pixels[offset + 3] > 240 && distance <= 48) matching++;
sampled++;
}
}
return {
id: probe.id, matching, sampled,
fraction: sampled ? matching / sampled : 0,
pixel: [cx, cy],
};
});
}, { png64: png.toString('base64'), clip, spec });
}
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
* glyph pixels, not just an empty surface or a stale open-state flag. */
async function countHelpTextPixels(page, png, clip, spec) {
@@ -565,6 +620,18 @@ try {
);
}
}
if (scenario.decorPixelProbes) {
const samples = await inspectDecorPixels(page, actual, clip, scenario.decorPixelProbes);
result.decorPixelProbes = samples;
const failed = samples.filter((sample) =>
sample.fraction < scenario.decorPixelProbes.minMatchingFraction);
if (failed.length) {
throw new Error(
`semantic golden assertion failed: decor is hidden/tinted at `
+ failed.map((sample) => `${sample.id} (${sample.matching}/${sample.sampled})`).join(', '),
);
}
}
if (scenario.helpTextRegion) {
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
result.helpTextPixels = sample.textPixels;
+251
View File
@@ -0,0 +1,251 @@
/**
* #231: decor is one composition layer above every floor treatment and below
* live light, sun, physical geometry, opening symbols and HTML overlays.
*
* The raster probes are intentional. A DOM-only assertion used to pass while
* an opaque room fill still erased decor from the rendered plan.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 });
const stage = page.locator('houseplan-card').locator('.stage').first();
const fixture = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.shadowRoot || c.renderRoot;
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const wall = (a, b) => ({ key: wallKey(a, b), a, b, cm: 30 });
const update = async () => {
c._cfgEpoch++;
c.requestUpdate();
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const roomPoly = [[0.1, 0.15], [0.9, 0.15], [0.9, 0.85], [0.1, 0.85]];
sp.plan_url = null;
sp.view_box = [0, 0, 1, 1];
sp.cell_cm = 5;
sp.rooms = [{
id: 'decor-order-room', name: 'Decor room', area: 'decor_order_area', poly: roomPoly,
settings: {},
}];
sp.walls = roomPoly.map((a, index) => wall(a, roomPoly[(index + 1) % roomPoly.length]));
sp.openings = [
{ id: 'decor-order-window', type: 'window', x: 0.5, y: 0.15, angle: 0, length: 0.24 },
];
sp.decor = [
{ id: 'decor-order-line', kind: 'line', x1: 0.5, y1: 0.04, x2: 0.5, y2: 0.96,
color: '#ff0033', opacity: 1, width_cm: 8 },
{ id: 'decor-order-rect', kind: 'rect', x: 0.18, y: 0.27, w: 0.18, h: 0.12,
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
fill_color: '#ff6680', fill_opacity: 1 },
{ id: 'decor-order-ellipse', kind: 'ellipse', x: 0.64, y: 0.27, w: 0.18, h: 0.12,
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
fill_color: '#ff6680', fill_opacity: 1 },
{ id: 'decor-order-text', kind: 'text', x: 0.27, y: 0.68, text: 'DECOR',
color: '#ff0033', opacity: 1, size_cm: 14 },
{ id: 'decor-order-furniture', kind: 'furniture', symbol: 'sofa',
x: 0.64, y: 0.61, w: 0.18, h: 0.13, color: '#ff0033', opacity: 1, width_cm: 2 },
];
sp.settings = {
...(sp.settings || {}), fill_mode: 'custom', custom_fill: { c: '#2255cc', a: 1 },
show_borders: false, show_names: false, hide_openings: true,
hide_decor: false, glow_enabled: false, sun_rays: false,
};
c._setMode('view');
await update();
const svg = root().querySelector('.stage .zoomwrap > svg');
const stageRect = root().querySelector('.stage').getBoundingClientRect();
const ctm = svg.getScreenCTM();
const toStage = (x, y) => {
const point = svg.createSVGPoint(); point.x = x; point.y = y;
const screen = point.matrixTransform(ctm);
return [screen.x - stageRect.left, screen.y - stageRect.top];
};
const decor = root().querySelector('.decorlayer');
const room = root().querySelector('[data-hp="room"][data-id="decor-order-room"]');
const dataTunnel = root().querySelector(
'.opening-tunnels[data-layer="data"] [data-id="decor-order-window"]',
);
const follows = (lower, upper) => !!lower && !!upper
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
const kinds = new Set([...root().querySelectorAll('.decorlayer [data-hp="decor"]')]
.map((node) => node.dataset.kind));
return {
before: {
allDecorKindsRender: ['line', 'rect', 'ellipse', 'text', 'furniture']
.every((kind) => kinds.has(kind)),
oneDecorLayer: root().querySelectorAll('.decorlayer').length === 1,
dataFloorBeforeDecor: follows(room, decor) && follows(dataTunnel, decor),
dataTunnelExists: !!dataTunnel,
},
probes: { room: toStage(500, 500), tunnel: toStage(500, 150) },
stored: JSON.stringify(sp.decor),
};
});
const customShot = await stage.screenshot({ animations: 'disabled' });
const hover = await page.evaluate(async () => {
const c = window.__card;
const root = c.shadowRoot || c.renderRoot;
const room = root.querySelector('[data-hp="room"][data-id="decor-order-room"]');
room?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const hoverFill = root.querySelector('.room-hover-fill-layer');
const decor = root.querySelector('.decorlayer');
return {
hoverFillExists: !!hoverFill,
hoverBeforeDecor: !!hoverFill && !!decor
&& !!(hoverFill.compareDocumentPosition(decor) & Node.DOCUMENT_POSITION_FOLLOWING),
};
});
const hoverShot = await stage.screenshot({ animations: 'disabled' });
const glow = await page.evaluate(async () => {
const c = window.__card;
const root = c.shadowRoot || c.renderRoot;
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
root.querySelector('[data-hp="room"][data-id="decor-order-room"]')?.dispatchEvent(
new PointerEvent('pointerleave', { pointerType: 'mouse' }),
);
sp.rooms[0].settings = {};
sp.settings = {
...sp.settings, fill_mode: 'glow', glow_enabled: true,
show_borders: false, hide_openings: true, sun_rays: false,
};
c._serverCfg.settings = {
...(c._serverCfg.settings || {}),
fill_colors: {
...(c._serverCfg.settings?.fill_colors || {}),
glow_base: { c: '#2255cc', a: 1 },
},
};
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const base = root.querySelector('.glow-base-layer');
const tunnel = root.querySelector(
'.opening-tunnels[data-layer="glow-base"] [data-id="decor-order-window"]',
);
const decor = root.querySelector('.decorlayer');
const liveGlow = root.querySelector('.glow-pools-frame');
const follows = (lower, upper) => !!lower && !!upper
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
return {
glowBaseExists: !!base,
glowTunnelExists: !!tunnel,
glowBaseBeforeDecor: follows(base, decor) && follows(tunnel, decor),
decorBeforeLiveGlow: follows(decor, liveGlow),
};
});
const glowShot = await stage.screenshot({ animations: 'disabled' });
const decodePixels = await page.evaluate(async ({ shots, probes }) => {
const decode = async (base64) => {
const image = new Image();
image.src = `data:image/png;base64,${base64}`;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = image.width; canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
return { width: image.width, height: image.height,
pixels: context.getImageData(0, 0, image.width, image.height).data };
};
const images = await Promise.all(shots.map(decode));
const isDecorRed = ({ width, height, pixels }, [rawX, rawY]) => {
let red = 0; let samples = 0;
const cx = Math.round(rawX), cy = Math.round(rawY);
for (let y = cy - 2; y <= cy + 2; y++) for (let x = cx - 2; x <= cx + 2; x++) {
if (x < 0 || y < 0 || x >= width || y >= height) continue;
const offset = (y * width + x) * 4;
const r = pixels[offset], g = pixels[offset + 1], b = pixels[offset + 2];
if (r > 180 && r > g * 1.8 && r > b * 1.35) red++;
samples++;
}
return samples > 0 && red / samples >= 0.6;
};
const verdict = (image) => ({
room: isDecorRed(image, probes.room),
tunnel: isDecorRed(image, probes.tunnel),
});
return images.map(verdict);
}, {
shots: [customShot, hoverShot, glowShot].map((shot) => shot.toString('base64')),
probes: fixture.probes,
});
const parity = await page.evaluate(async (stored) => {
const c = window.__card;
const root = c.shadowRoot || c.renderRoot;
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
const update = async () => {
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
sp.settings = {
...sp.settings, show_borders: true, hide_openings: false,
sun_rays: true, north_deg: 0,
};
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 0, elevation: 25, rising: true },
} } };
await update();
const decor = root.querySelector('.decorlayer');
const follows = (lower, upper) => !!lower && !!upper
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
const liveGlow = root.querySelector('.glow-pools-frame');
const sun = root.querySelector('.sunlayer');
const walls = root.querySelector('.wallbodies');
const opening = root.querySelector('[data-hp="opening"][data-id="decor-order-window"]');
const devLayer = root.querySelector('.devlayer');
const paper = root.querySelector('.hp-paperg');
const out = {
backdropBeforeDecor: follows(paper, decor),
liveGlowAfterDecor: follows(decor, liveGlow),
sunAfterDecor: follows(decor, sun),
wallsAfterDecor: follows(decor, walls),
openingSymbolsAfterDecor: follows(decor, opening),
htmlDevicesAndLabelsAfterDecor: follows(decor, devLayer),
renderDoesNotRewriteDecor: JSON.stringify(sp.decor) === stored,
};
sp.settings.hide_decor = true;
c._setMode('view'); await update();
out.hideDecorStillHidesView = !root.querySelector('.decorlayer');
c._setMode('decor'); await update();
out.ownEditorOverridesHide = !!root.querySelector('.decorlayer')
&& root.querySelectorAll('.decorlayer [data-hp="decor"]').length >= 5;
return out;
}, fixture.stored);
const result = {
...fixture.before,
...hover,
...glow,
customFillKeepsDecorPixels: decodePixels[0].room,
customTunnelKeepsDecorPixels: decodePixels[0].tunnel,
hoverDoesNotTintDecorPixels: decodePixels[1].room,
hoverTunnelKeepsDecorPixels: decodePixels[1].tunnel,
glowBaseKeepsDecorPixels: decodePixels[2].room,
glowTunnelKeepsDecorPixels: decodePixels[2].tunnel,
...parity,
};
checkAll(result);
await finish(browser, result);
+4 -2
View File
@@ -246,6 +246,7 @@ const res = await page.evaluate(async () => {
const hoverHalo = sr().querySelector('.room-hover-halo');
const hoverOutline = sr().querySelector('.room-hover-outline');
const hoverOutlineLayer = sr().querySelector('.room-hover-outline-layer');
const decorLayer = sr().querySelector('.decorlayer');
const glowLayer = sr().querySelector('.glow-pools-frame');
const wallLayer = sr().querySelector('.wallbodies');
out.hoverKeepsGlowDom = poolBeforeHover === sr().querySelector('.glow-pool')
@@ -257,8 +258,9 @@ const res = await page.evaluate(async () => {
const fillStyle = hoverFill ? getComputedStyle(hoverFill) : null;
out.hoverUsesNeutralDarkening = fillStyle?.fill === 'rgb(0, 0, 0)'
&& Math.abs(Number(fillStyle.fillOpacity) - 0.22) < 0.001;
out.hoverLayerOrder = !!hoverFillLayer && !!glowLayer && !!hoverOutlineLayer
&& !!(hoverFillLayer.compareDocumentPosition(glowLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
out.hoverLayerOrder = !!hoverFillLayer && !!decorLayer && !!glowLayer && !!hoverOutlineLayer
&& !!(hoverFillLayer.compareDocumentPosition(decorLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
&& !!(decorLayer.compareDocumentPosition(glowLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
// The default smoke fixture has no thick wall body. When one exists, the
// late outline must follow it; absence is not a layer-order failure.
&& (!wallLayer
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -51,7 +51,11 @@ Layer order, top to bottom:
```text
devices and room labels
sun / walls / openings / rooms / decor
opening symbols / physical and virtual walls / late room-hover outline
sun rays
live Glow pools
decor
room hover fill / Glow-base rooms and tunnels / data room fills and tunnels
plan image
room-shaped paper
scene background
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Decorative lines, shapes, text and furniture are now drawn above opaque room
fills, room hover, doorway fill and the dark Glow base, so the floor no
longer hides them. Live light and sun, walls, opening symbols, devices and
room labels keep their existing position above decor
([#231](https://github.com/Matysh/houseplan-card/issues/231)).
## v1.67.0-beta.1 — 2026-08-22
- “Optimize plans” now verifies the exact preview candidate with the same wall,
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Декоративные линии, фигуры, текст и мебель теперь рисуются поверх непрозрачной
заливки комнат, hover-подсветки, заливки проёмов и тёмной основы Glow, поэтому
пол больше не скрывает декор. Живой свет и солнечные лучи, стены, символы
проёмов, устройства и названия комнат по-прежнему остаются над декором
([#231](https://github.com/Matysh/houseplan-card/issues/231)).
## v1.67.0-beta.1 — 2026-08-22
- «Оптимизировать планы» теперь проверяет точный кандидат из предпросмотра теми
+1
View File
@@ -21,6 +21,7 @@ device state or Home Assistant actions.
| Rotation | 5° steps by default; `Shift` gives free rotation. Lines use endpoint handles instead of a rotation handle. |
| Magnet targets | Only other decor objects and room contours: corners, edge centres, centres and edges. The image, devices and openings are excluded. |
| Context emphasis | Decor and its editing chrome stay fully opaque. Rooms, labels, devices, openings, solid/thick walls and dashed virtual walls are contextual only and render at 35% opacity. |
| View composition | All decor kinds form one layer above room/data fills, room hover fill, opening-tunnel fills and Glow base. Live Glow, sun, physical walls, opening symbols, devices and room labels remain above decor. The plan image remains below it. |
| Compatibility | Legacy `width`, text `size/scale` and `plan_scale` remain readable. New writes use `width_cm`, `size_cm` and `plan_scale_x/y`. |
## Tools
+23
View File
@@ -32,6 +32,29 @@
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
## Decor composition order (#231)
- [ ] All five decor kinds render in one `.decorlayer` after opaque room/data
fill, active room-hover fill, opening tunnels and Glow-base rooms/tunnels,
but before live Glow, sun, physical walls, opening symbols and the HTML
device/room-label layer [auto: `smoke_decor_layer_order.mjs`,
`smoke_glow.mjs`].
- [ ] Pixel probes through an opaque room and a filled opening tunnel stay the
decor colour before/after hover and over Glow base. Restoring the old DOM
order makes those probes red [auto: `smoke_decor_layer_order.mjs`;
mutation: `decor-restored-below-room-fills`].
- [ ] The complete #231 golden impact set is reviewed before baseline
acceptance. The two dedicated Light/opaque-hover and Dark/Glow-base
scenes contain all five decor types and semantic probes in both rooms and
the shared doorway. The three existing large-house scenes also change
because their dense decor grid now renders above Glow-base room fills.
Reviewed baselines are accepted only from the Linux release artifact
[golden: `decor-over-opaque-hover-light`,
`decor-over-glow-base-dark`, `isometric-large-warm-remount-dark`,
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`].
- [ ] `hide_decor`, the Background editor override and stored config remain
unchanged; no per-object under-plan compatibility flag is introduced.
## Device icon design package (#179)
- [ ] Pure presentation tests cover lock/unlock, exact marker-only LQI bands
Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 329 KiB

+13 -13
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceFingerprint": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -46,8 +46,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"imageSha256": "3d22e1a2a96823e443833e40ea8b93747099b0fae1e9e19315d8789ae4604997"
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "f8cba93960e94371a22c6c1c8451cbaac0742fd0f50ae2050e5cd99bcf95adc4"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -57,8 +57,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "d5f67be890d6ad6bd810e202c26d922154476bc962fc017f0f95fa4455ef7fe2"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
}
}
+204
View File
@@ -0,0 +1,204 @@
# CODE-REVIEW-231-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/231
- **ТЗ:** `docs/specs/231-decor-layer-order.md` (зелёный SPEC-REVIEW r2)
- **Диапазон:** `git log --oneline origin/dev..HEAD` → один коммит
`b747316` «fix: render decor above room fills» (`Issue: #231`,
`User-Visible: yes`)
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4
## Скоуп
Единственное продуктовое изменение: перенос вызова `_renderDecorLayer()` в
`src/houseplan-card.ts` из позиции перед рендером комнат в позицию после
активной hover-заливки комнаты, обоих проходов тоннелей проёмов и Glow-base
комнат — то есть непосредственно перед `_renderGlowLayer()`, как того требует
§8.1 ТЗ. Сопутствующие изменения: новый browser-smoke
`demo/smoke_decor_layer_order.mjs`, расширение `demo/smoke_glow.mjs`
(`decorLayer` теперь участвует в `hoverLayerOrder`), новый documented mutant
`decor-restored-below-room-fills` в `scripts/mutation-gate.mjs`, две новые
golden-сцены (`decor-over-opaque-hover-light`, `decor-over-glow-base-dark`) с
семантическими пиксельными пробами в `demo/golden/{matrix,harness,run}.mjs` и
юнит-контракт на них в `test/golden-matrix.test.mjs`, документация
(`docs/BACKDROP.md`, `docs/DECOR-EDITOR.md`, `docs/TESTING.md`, оба
changelog), три синхронные копии бандла и пересчитанный отпечаток
`docs/images/screenshots.json`.
## Как проверялось
Свежий чекаут ветки `issue/231-decor-layer-order` на `b747316`, `origin/dev`
на момент ревью — `6683e52`.
### Гейты — обязательная часть (прогнаны все)
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | зелёный, 1070/1070 pass (0 skip — на CI runner’е автора один тест `process-gate.test.mjs` пропускался, окружение без `gh`; на поведение #231 не влияет) |
| Build + bundle parity | `npm run build`, затем `cmp` трёх копий и `sha256sum` | зелёный, все три `houseplan-card.js` (`dist`, `custom_components/houseplan/frontend`, `demo/srv/assets`) байт-в-байт идентичны (`baf60c9a…`) |
| Docs fingerprint | `node scripts/check-docs.mjs` | зелёный: «Documentation checks passed (7 files, 10 external links)» — diff трогает `src/**`, поэтому гейт обязателен, не пропущен |
### Гейты — по необходимости (diff и AC)
| Гейт | Команда | Результат | Почему выбран |
|---|---|---|---|
| Mutation registry (статика) | `node scripts/mutation-gate.mjs --check` | зелёный, все id, включая новый | якоря патчей мутанта существуют ровно один раз |
| Mutation run (AC5, доказательство «тест умеет падать») | `node scripts/mutation-gate.mjs --id=decor-restored-below-room-fills` | зелёный: «чистый прогон: `node demo/smoke_decor_layer_order.mjs`» → ok, «decor-restored-below-room-fills: тест покраснел, как обязан» | AC5 явно требует это доказательство, а не только регистрацию мутанта |
| Целевой smoke (AC1–AC4) | `node demo/smoke_decor_layer_order.mjs` | зелёный, все 25 полей `true`, включая DOM-order и пиксельные пробы hover/tunnel/glow-base | назван в АС1–АС4 и в плане тестов §15.1 |
| Glow layer order (AC2/AC3, hover×decor×glow) | `node demo/smoke_glow.mjs` | зелёный, `hoverLayerOrder: true` и весь остальной Glow-контракт | назван в плане тестов §15.1 и напрямую правится в этом diff |
| Opening tunnel fill (AC2, тоннель как продолжение пола) | `node demo/smoke_opening_tunnel_fill.mjs` | зелёный | тоннели — предмет нормативной границы §8.1, смок их проверяет отдельно от decor |
| Hide/editor parity (AC4) | `node demo/smoke_hide_layers.mjs` | зелёный | явно назван в AC4 |
| Decor editor поведение (AC4/AC6, не regresses) | `node demo/smoke_decor.mjs` | зелёный, включая `decorRestored`, `visibleInView`, `inertInView` | самый прямой существующий смок по объекту правки |
| Backdrop parity (AC4, backdrop ниже decor) | `node demo/smoke_backdrop.mjs` | зелёный | явно назван в AC4 и §9 |
| Golden (AC1/AC8, диф может менять видимый результат) | `npm run golden:verify` | см. раздел «Находки», M1 | обязателен по правилам гейтов — diff меняет геометрию/порядок слоёв |
| Mutation unit-контракт | `node --test test/mutation-gate.test.mjs` | зелёный, 6/6 | регистр мутантов и test-build резолвятся |
### Не прогонялось и почему
- Полный `demo/smoke_*.mjs` (167 файлов) — не прогонялся. `scripts/smoke-select.mjs`,
на который ссылается инструкция ревью, в репозитории отсутствует
(`Cannot find module .../scripts/smoke-select.mjs`), поэтому автоматической
выборки нет; список выше подобран вручную по AC и по diff (Glow, тоннели,
hide/editor/backdrop — единственные существующие smokes, которые diff и ТЗ
называют напрямую). Остальные 160+ смоков не касаются композиции SVG-слоёв.
- `python -m pytest tests_backend -q` — не прогонялся, diff не трогает
`custom_components/**/*.py`.
- Performance-профили — не прогонялись, AC7 требует только «DOM count, source
review и targeted performance sanity»; смок `smoke_decor_layer_order.mjs`
уже проверяет `oneDecorLayer: true` (нет второго `.decorlayer`), число
decor-групп не растёт, что и требуется. Отдельного профиля AC не называет.
- Ручного тестирования в браузере не было — не предусмотрено циклом (см.
«Гейты», PROCESS §2.7); AC доказаны автотестами и разбором по коду.
## Находки
### M1 (Medium, в скоупе) — golden regression на трёх существующих large-house сценах не определена и не учтена
Issue #231 (п.4) прямо требует: «Любая сцена с декором изменится. Определить
список и переснять отдельным шагом с доказательством ревью». ТЗ AC8 требует
актуальное «golden evidence». Handoff-комментарий разработчика утверждает:
«Полный `npm run golden:verify` → ожидаемо красный: две новые сцены имеют
`missing-baseline`, а текущий `dev` уже содержит множество ранее накопленных
непринятых diff».
Я прогнал `npm run golden:verify` на `HEAD` (`b747316`) и получил, помимо двух
ожидаемых `missing-baseline`, ещё три сценария с вердиктом `different`:
`isometric-large-warm-remount-dark`, `large-house-zoom-250-dark`,
`large-house-warm-remount-dark`.
Проверил заявление о «уже накопленном dev-дрейфе» напрямую: собрал `origin/dev`
(`6683e52`) в отдельном `git worktree`, собрал бандл и прогнал те же три
сценария через `node demo/golden/run.mjs --mode=capture --scenario=<id>`
(diagnostic-режим, `--mode=verify` требует полный матрикс и отказывает на
`--scenario`) — **все три `passed` против того же самого принятого baseline**.
То есть регрессия появляется именно между `origin/dev` и этим коммитом, а не
существовала на `dev` заранее — причина утверждения в handoff неверна для этих
трёх сценариев.
Причина найдена по коду: фикстура `demo/fixtures/large-house.mjs` кладёт 500
decor-объектов сеткой (`makeDecor`, x:0.02–0.98, y:0.018–0.97) поверх
20-комнатной сетки (`roomGrid`, x:0.04–0.96, y:0.04–0.96) с `fill_mode: 'glow'`
(`large-house.mjs:214`) — то есть decor обширно перекрывает Glow-base заливку
комнат. Это ровно тот же эффект, что и цель #231 (decor становится видимым
поверх Glow-base), только на perf-фикстуре, а не на новой синтетической. При
zoom 0.4 (`large-house-zoom-040-dark`) разница остаётся под порогом
`maxDiffRatio` и сценарий проходит; при zoom 2.5 и в warm-remount сценариях
рендер крупнее и разница превышает порог — отсюда `different`.
Сам визуальный эффект — ожидаемое и корректное следствие фикса (Приложение
пруф выше: изменения соответствуют цели задачи, а не побочный дефект
рендера). Дефект не в поведении, а в полноте DoD: список затронутых golden
сцен (issue п.4, ТЗ AC8, `docs/TESTING.md`) называет только две новые сцены;
три существующие accepted-сцены, тоже реально изменившиеся из-за этого же
коммита, не определены, не занесены ни в `docs/TESTING.md`, ни в комментарий с
доказательством. Риск: на предрелизном `golden:accept -- --reviewed` эти три
скриншота будет легко спутать с «уже накопленным неразобранным дрейфом» (как и
произошло в handoff-комментарии) и принять непроверенными либо, наоборот,
несправедливо связать с #231 задним числом без объяснения причины.
Не High: поведенческий AC1–AC7 не нарушен, ничего не ломается для
пользователя в этом коммите — golden baseline в этом issue не принимается
вообще (по правилу принятия эталонов), сама заливка не тронута. Это пробел в
трассируемости релизного артефакта и неточное заявление в handoff, а не
неверное продуктовое решение.
**Как закрыть в этой же задаче:** дополнить список сцен, изменившихся из-за
переноса decor (минимум `isometric-large-warm-remount-dark`,
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`) в
`docs/TESTING.md`/комментарий к задаче, и заменить формулировку «уже
накопленный dev-дрейф» на подтверждённую причину — пересечение
`large-house`-decor с Glow-base после переноса слоя, которое требует того же
reviewed-baseline-подтверждения перед бетой, что и две новые сцены.
## Что проверено и корректно
- **AC1 (decor поверх room fill).** `demo/smoke_decor_layer_order.mjs`:
`customFillKeepsDecorPixels`, `customTunnelKeepsDecorPixels` — `true`;
прогон против кода до фикса (мутант) даёт красный, значит проверка не
тривиальна.
- **AC2 (decor поверх hover и пола проёма).** DOM-order
`hoverBeforeDecor`/`glowBaseBeforeDecor` и пиксельные `hoverDoesNotTintDecorPixels`,
`hoverTunnelKeepsDecorPixels`, `glowBaseKeepsDecorPixels`,
`glowTunnelKeepsDecorPixels` — все `true`; `smoke_glow.mjs`
`hoverLayerOrder: true` подтверждает то же на независимой fixture. M1 из
SPEC-REVIEW r1 закрыта именно здесь — код `src/houseplan-card.ts:16720-16729`
ставит decor строго после `_renderRoomHoverFill` и обоих
`_renderOpeningTunnelFills`.
- **AC3 (верхние слои сохранены).** `parity`-блок smoke:
`liveGlowAfterDecor`, `sunAfterDecor`, `wallsAfterDecor`,
`openingSymbolsAfterDecor`, `htmlDevicesAndLabelsAfterDecor` — все `true`;
подтверждено чтением `src/houseplan-card.ts:16729-16731` (decor → Glow →
sun rays, стены/символы/устройства дальше по файлу не переставлялись).
- **AC4 (hide/editor/backdrop parity).** `hideDecorStillHidesView`,
`ownEditorOverridesHide`, `backdropBeforeDecor` — `true`;
`smoke_hide_layers.mjs`, `smoke_decor.mjs`, `smoke_backdrop.mjs` — все
зелёные без изменений в их собственной логике (diff их не трогает).
- **AC5 (mutant доказывает тест).** Реально прогнан
`node scripts/mutation-gate.mjs --id=decor-restored-below-room-fills`:
чистый прогон зелёный, с мутантом — целевой smoke красный. Не «зарегистрирован»,
а исполнен.
- **AC6 (compatibility и surfaces).** `renderDoesNotRewriteDecor: true`
(`JSON.stringify(sp.decor) === stored` после полного цикла рендера);
`oneDecorLayer: true` — второй `.decorlayer` не создан; статическая карточка
и скрытая изометрия в diff не затронуты (изменений в соответствующих
renderer'ах нет).
- **AC7 (performance/security).** Diff — это перемещение одного вызова и
одного komментария, новых groups/observers/listeners не добавлено; `oneDecorLayer: true`
подтверждает отсутствие дублирования слоя.
- **Q1 (владелец).** Единый порядок без нового флага — подтверждено кодом:
`disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()`
не получил новых веток/условий по объекту.
- **Release-артефакты.** Оба changelog обновлены в том же коммите (`Issue:
#231`, `User-Visible: yes`); `docs/BACKDROP.md` и `docs/DECOR-EDITOR.md`
описывают новый порядок текстом, совпадающим с фактическим кодом;
`docs/images/screenshots.json` — отпечаток пересчитан, `check-docs.mjs`
зелёный; три копии бандла синхронны.
- **Golden — новые сцены.** `decor-over-opaque-hover-light` и
`decor-over-glow-base-dark` дают `missing-baseline`, что ожидаемо (baseline
не принимается в этом ревью); `test/golden-matrix.test.mjs` покрывает их
структуру юнитом (fill_mode, hoverRoom/glowEnabled, набор decor kinds,
наличие `opening-tunnel` пробы) — прогнан в составе `npm test`.
- **Существующие golden-сцены с decor (кроме large-house).** `golden-geometry`
использует `fill_mode: 'none'` по умолчанию — decor-линия `geo-axis-h`
визуально не перекрывалась заливкой ни до, ни после переноса, поэтому 30+
сцен на этом пространстве (`geometry-view-*`, `day-cycle-*`,
`isometric-geometry-view-*`, `hover-nested-room-dark` и др.) остались
`passed` — подтверждено прогоном `golden:verify`, не только чтением.
`hover-nested-room-dark` дополнительно проверен геометрически: полигон
`geo-nested` (y: 0.14–0.38) не пересекает decor-линию на y=0.5, поэтому
hover-fill и decor не перекрываются пространственно в этом сценарии.
## Чего не проверял
- Полный browser-smoke набор (167 файлов) — не прогонялся целиком, только
выбранные вручную из-за отсутствия `scripts/smoke-select.mjs` в репозитории
(см. «Не прогонялось и почему»).
- `pytest tests_backend` — не прогонялся, diff не касается Python.
- Performance-профили (`benchmark:large-house` и т.п.) — не прогонялись;
AC7 не называет отдельный профиль, а `oneDecorLayer`/DOM count проверены
smoke'ом.
- Полный Linux golden-артефакт и его reviewed-принятие — вне цикла код-ревью
по правилу принятия эталонов (§13 PROCESS.md); это предрелизный шаг, не
код-ревью.
- Ручной просмотр в браузере на реальном HA — не проводился (не предусмотрено
циклом).
+136
View File
@@ -0,0 +1,136 @@
# CODE-REVIEW-231-r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/231
- **ТЗ:** `docs/specs/231-decor-layer-order.md` (зелёный SPEC-REVIEW r2)
- **Диапазон полной задачи:** `git log --oneline origin/dev..HEAD` →
`e023adb`, `9410be6`, `d2e7626`, `b747316` (продуктовый фикс, `Issue: #231`,
`User-Visible: yes`), `e943e2f`, `d46abe5`, `6d31553`
- **Диапазон этого раунда (дельта r1→r2):** `b747316..6d31553` — SHA
предыдущего раунда взят из тела документа r1 (`docs/reviews/CODE-REVIEW-231-r1.md`,
«Диапазон» и «Как проверялось») и из вердикта r1 в issue (комментарий
цитирует «на HEAD (`b747316`)»); формальная первая строка вердикта r1 сам
SHA не называет — это соответствует ожидаемому формату §7.2, находкой не
является, так как SHA восстанавливается однозначно из тела того же
комментария и документа
- **Заход:** r2 · блокирующих циклов израсходовано 1 из 4
## Скоуп раунда (по дельте)
`git diff b747316..6d31553 --stat`:
```
docs/TESTING.md | 14 ++-
docs/reviews/CODE-REVIEW-231-r1.md | 204 +++++++++++++++++++++++++++++++++++
```
Продуктовый код (`src/houseplan-card.ts`, три копии бандла, тесты, smoke,
golden matrix/harness) в дельте не тронут вообще — коммит `d46abe5`
архивирует документ r1 в `docs/reviews/` (публикующий шаг, не авторская
правка), а `6d31553` — единственная содержательная правка автора: расширение
чек-листа `docs/TESTING.md` до полного списка golden-сцен, затронутых #231.
Это ровно и только предмет находки M1 из r1. Дельта локальна: новая
подсистема не затронута, контракт поведения не менялся, бандл не
пересобирался (SHA-256 идентичен r1).
## Закрытие раунда r1
| Находка | Чем закрыта | Где это видно |
|---|---|---|
| **M1** (Medium, в скоупе) — список golden-сцен, затронутых переносом decor, называл только 2 новые сцены; 3 существующие large-house сцены (`isometric-large-warm-remount-dark`, `large-house-zoom-250-dark`, `large-house-warm-remount-dark`) реально меняются тем же коммитом, но не были задокументированы, а причина в handoff-комментарии («уже накопленный dev-дрейф») была ошибочной | `docs/TESTING.md` теперь перечисляет все 5 сцен в одном пункте чек-листа и формулирует подтверждённую причину: «The three existing large-house scenes also change because their dense decor grid now renders above Glow-base room fills» — вместо прежней ссылки только на 2 новые сцены без объяснения. Ошибочное объяснение «дрейф dev» не повторено нигде в новом тексте. Требование «reviewed baselines только из Linux release artifact» сохранено для всех пяти сцен | `docs/TESTING.md:46-54` (см. diff выше); текстовая правка причины дополнительно зафиксирована в новом комментарии автора в issue #231 (`IC_kwDOTOcLQM8AAAABQL-q0w`, 2026-08-22 15:53), который прямо отменяет прежнюю формулировку |
Проверил построчно: три id сцен (`isometric-large-warm-remount-dark`,
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`) реальны и
совпадают с определениями в `demo/golden/matrix.mjs:63,364,366` — не
опечатка и не выдуманные имена.
## Унаследовано из r1
Всё, до чего дельта `b747316..6d31553` не дотягивается, принято без
повторной проверки из `docs/reviews/CODE-REVIEW-231-r1.md` @ `b747316`
(`b74731698f664a9b0dc4c187b5b1b05ccf333b61`):
- AC1–AC7 (порядок decor относительно room fill, hover, обоих тоннелей,
Glow-base, live Glow/солнца/стен/символов/устройств), доказанные
`demo/smoke_decor_layer_order.mjs`, `demo/smoke_glow.mjs`,
`demo/smoke_opening_tunnel_fill.mjs`, `demo/smoke_hide_layers.mjs`,
`demo/smoke_decor.mjs`, `demo/smoke_backdrop.mjs` и мутантом
`decor-restored-below-room-fills` — код `src/houseplan-card.ts` в дельте не
менялся, бандл байт-в-байт идентичен (`sha256: baf60c9a89a8…`), поэтому
повторный прогон этих smoke не может дать новый результат;
- Q1 (владелец) — единый порядок decor без нового persisted-флага;
- AC8 в части структуры двух новых golden-сцен (`decor-over-opaque-hover-light`,
`decor-over-glow-base-dark`) и их `missing-baseline` статуса — сцены не
менялись;
- release-артефакты предыдущего коммита: оба changelog, `docs/BACKDROP.md`,
`docs/DECOR-EDITOR.md`, отпечаток `docs/images/screenshots.json`, три копии
бандла — не затронуты этой дельтой;
- вывод «AC7/perf» (`oneDecorLayer: true`, отсутствие новых
groups/observers/listeners) — код не менялся.
## Как проверялось в этом раунде
Дельта не трогает `src/**` и не меняет бандл, поэтому полный целевой набор
smoke/golden из r1 не переисполнялся — прогнаны только дешёвые обязательные
гейты, чтобы подтвердить, что дельта не сломала ничего наследуемого.
### Гейты — обязательная часть (прогнаны все)
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | зелёный, 1070/1070 pass, 0 skip (на этом раннере `gh` доступен, `process-gate.test.mjs` не пропускается) |
| Build + bundle parity | `npm run build`, затем `sha256sum` трёх копий | зелёный, все три `houseplan-card.js` идентичны и совпадают с зафиксированным в r1 хэшем `baf60c9a89a8…` — подтверждает, что дельта не изменила продуктовый вывод |
| Docs fingerprint | `node scripts/check-docs.mjs` | зелёный: «Documentation checks passed (7 files, 10 external links)» — формально не обязателен (дельта не трогает `src/**`), прогнан всё равно как дешёвый, т.к. дельта меняет `docs/TESTING.md` |
### Гейты — по необходимости
Не прогонялись повторно: `demo/smoke_decor_layer_order.mjs`, `smoke_glow.mjs`,
`smoke_opening_tunnel_fill.mjs`, `smoke_hide_layers.mjs`, `smoke_decor.mjs`,
`smoke_backdrop.mjs`, `npm run golden:verify`, `node scripts/mutation-gate.mjs`.
Причина: дельта этого раунда не касается `src/houseplan-card.ts`, тестовых
фикстур, golden-матрицы или mutation-gate — правка ограничена одним
Markdown-чек-листом. Бандл после `npm run build` в этом раунде byte-for-byte
идентичен бандлу r1 (тот же SHA-256), то есть материал, который эти гейты
проверяют, не изменился с r1 ни на бит. Повторный прогон дал бы тот же
результат, что уже запротоколирован в `docs/reviews/CODE-REVIEW-231-r1.md`, и
был бы разбором заново, а не по дельте (PROCESS §2.9).
`python -m pytest tests_backend -q` — не прогонялся, дельта не касается
Python. Performance-профили — не прогонялись, дельта не касается
производительности. Ручного тестирования в браузере не было — не
предусмотрено циклом.
## Находки
Новых находок нет. M1 закрыта полностью (см. «Закрытие раунда r1»).
## Что проверено и корректно
- Список из пяти golden-сцен в `docs/TESTING.md` полный и точный:
2 новые + 3 существующие large-house сцены, все id проверены по существующей
golden-матрице (`demo/golden/matrix.mjs:63,364,366`).
- Формулировка причины изменения существующих сцен верна и не противоречит
разбору r1 (плотная decor-сетка `large-house` теперь рендерится поверх
Glow-base заливки — прямое следствие переноса слоя, не побочный дефект).
- Правило «reviewed baseline только из Linux release artifact» сохранено для
всех пяти сцен без исключений.
- Продуктовый код, тесты, smoke и golden-матрица не изменены этой дельтой —
подтверждено идентичностью SHA-256 бандла и отсутствием diff вне
`docs/TESTING.md` и архивного `docs/reviews/CODE-REVIEW-231-r1.md`.
- Трейлеры коммитов дельты (`d46abe5`, `6d31553`) — `Issue: #231`,
`User-Visible: no`; верно, так как правка касается только внутреннего
тест-плана, не пользовательского поведения — changelog не требуется.
## Чего не проверял
- Полный browser-smoke набор и целевые smoke из r1 (`smoke_decor_layer_order.mjs`,
`smoke_glow.mjs`, `smoke_opening_tunnel_fill.mjs`, `smoke_hide_layers.mjs`,
`smoke_decor.mjs`, `smoke_backdrop.mjs`) — не перепрогонялись в этом раунде,
наследуются из r1 (см. «Унаследовано из r1»); дельта их не касается, бандл
идентичен.
- `npm run golden:verify` и `mutation-gate` — не перепрогонялись, дельта не
меняет рендер, геометрию, тестовые фикстуры или мутантные патчи.
- `pytest tests_backend` — не прогонялся, дельта не касается Python.
- Полный Linux golden-артефакт и его reviewed-принятие — вне цикла код-ревью
(предрелизный шаг, §13 PROCESS.md), в этом раунде сам список сцен для
будущего принятия проверен и признан полным.
+26 -4
View File
@@ -440,6 +440,30 @@ export const MUTANTS = [
replace: 'this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, [],',
}],
},
{
id: 'decor-restored-below-room-fills',
guard: 'node demo/smoke_decor_layer_order.mjs',
because: 'moving decor back before room fills reproduces #231: opaque custom floors, room '
+ 'hover and Glow base erase stored decor even though the decor group still exists in the DOM; '
+ 'the raster probes must catch the visual regression, including the filled opening tunnel',
patches: [{
file: 'src/houseplan-card.ts',
find: " ${''/* Decor is one composition layer above every floor treatment\n"
+ ' (room fill/hover, opening tunnels and Glow base) and below\n'
+ ' live lighting, physical plan geometry and devices. Keep\n'
+ ' hide_decor visual-only: the decor editor must always paint\n'
+ ' stored shapes so they remain editable. */}\n'
+ " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}\n",
replace: '',
}, {
file: 'src/houseplan-card.ts',
find: ' ${(() => {\n'
+ ' // audit L1: hoisted out of the per-room map — these depend on the',
replace: " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}\n"
+ ' ${(() => {\n'
+ ' // audit L1: hoisted out of the per-room map — these depend on the',
}],
},
{
id: 'feather-20px',
guard: 'node demo/smoke_glow.mjs',
@@ -758,10 +782,8 @@ export const MUTANTS = [
+ 'the smoke must reject the legacy centred SVG substitute independently of compact and iso',
patches: [{
file: 'src/houseplan-card.ts',
find: " ${this._renderOpeningTunnelFills(space, glowBase, 'glow-base')}\n"
+ ' ${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}',
replace: " ${this._renderOpeningTunnelFills(space, glowBase, 'glow-base')}\n"
+ ' ${!space.bg && !disp.showNames && !this._markup ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
find: ' ${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}',
replace: ' ${!space.bg && !disp.showNames && !this._markup ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
+ ' const center = this._roomCenter(room);\n'
+ ' return svg`<text class="rlabel" data-hp="room-label" data-id=${room.id || nothing}\n'
+ ' data-area=${room.area || nothing} x=${center[0]} y=${center[1]}>${room.name}</text>`;\n'
+6 -6
View File
@@ -16576,12 +16576,6 @@ class HouseplanCard extends LitElement {
@dblclick=${(e: Event) => this._openBackdropDialog(e)}
preserveAspectRatio="none" />`
: nothing}
${''/* «Скрыть декоративный слой» (space.hide_decor). The layer is
still THERE — the shapes are in the config and the decor
editor draws them as always, because a layer you cannot see
is a layer you cannot edit. Every other mode simply stops
painting it. */}
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}
${(() => {
// audit L1: hoisted out of the per-room map — these depend on the
// config, not on entity state, and were recomputed per room.
@@ -16727,6 +16721,12 @@ class HouseplanCard extends LitElement {
${this._renderOpeningTunnelFills(space, roomFills)}
${this._renderGlowBaseRooms(space, glowBase)}
${this._renderOpeningTunnelFills(space, glowBase, 'glow-base')}
${''/* Decor is one composition layer above every floor treatment
(room fill/hover, opening tunnels and Glow base) and below
live lighting, physical plan geometry and devices. Keep
hide_decor visual-only: the decor editor must always paint
stored shapes so they remain editable. */}
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}
${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}
${this._renderSunRays(space)}
${this._editing ? svg`<g class="hp-editor-only-layer"
+53 -2
View File
@@ -62,6 +62,25 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
&& scenario.tunnelContinuity.maxChannelJump <= 32, true, scenario.id);
assert.equal(typeof scenario.tunnelContinuity.dpr2, 'boolean', scenario.id);
}
if (scenario.decorPixelProbes) {
assert.match(scenario.decorPixelProbes.color, /^#[0-9a-f]{6}$/i, scenario.id);
assert.equal(Number.isInteger(scenario.decorPixelProbes.radius)
&& scenario.decorPixelProbes.radius >= 0, true, scenario.id);
assert.equal(scenario.decorPixelProbes.minMatchingFraction > 0
&& scenario.decorPixelProbes.minMatchingFraction <= 1, true, scenario.id);
assert.equal(scenario.decorPixelProbes.points.length >= 1, true, scenario.id);
for (const point of scenario.decorPixelProbes.points) {
assert.match(point.id, /^[a-z0-9-]+$/, scenario.id);
assert.equal(point.x >= 0 && point.x <= 1 && point.y >= 0 && point.y <= 1,
true, scenario.id);
}
}
if (scenario.decorOverride) {
assert.equal(new Set(scenario.decorOverride.map((shape) => shape.id)).size,
scenario.decorOverride.length, scenario.id);
assert.deepEqual(new Set(scenario.decorOverride.map((shape) => shape.kind)),
new Set(['line', 'rect', 'ellipse', 'text', 'furniture']), scenario.id);
}
if (scenario.helpTextRegion) {
assert.match(scenario.helpTextRegion.key, /^[a-z0-9_.-]+\.help$/, scenario.id);
assert.equal(Number.isInteger(scenario.helpTextRegion.minPixels)
@@ -98,7 +117,8 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
'washer-active-cycle', 'washer-idle-cycle'])
'washer-active-cycle', 'washer-idle-cycle', 'decor-over-opaque-hover',
'decor-over-glow-base'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
@@ -212,6 +232,34 @@ test('filled opening golden has a pixel-level seam detector', () => {
);
});
test('decor layer goldens pair opaque hover and Glow base with semantic pixels', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.decorPixelProbes);
assert.deepEqual(scenarios.map((item) => item.id), [
'decor-over-opaque-hover-light',
'decor-over-glow-base-dark',
]);
assert.deepEqual(scenarios.map((item) => item.theme), ['light', 'dark']);
for (const scenario of scenarios) {
assert.equal(scenario.showBorders, false);
assert.equal(scenario.showNames, false);
assert.equal(scenario.hideOpenings, true);
assert.equal(scenario.sunRays, false);
assert.equal(scenario.decorPixelProbes.points.some((point) => point.id === 'opening-tunnel'), true);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.deepEqual(new Set(space.decor.map((shape) => shape.kind)),
new Set(['line', 'rect', 'ellipse', 'text', 'furniture']));
assert.equal(space.settings.show_borders, false);
assert.equal(space.settings.hide_openings, true);
}
assert.equal(scenarios[0].fillMode, 'custom');
assert.equal(scenarios[0].customFill.a, 1);
assert.equal(scenarios[0].hoverRoom, 'light-left');
assert.equal(scenarios[1].fillMode, 'glow');
assert.equal(scenarios[1].glowEnabled, true);
assert.equal(scenarios[1].allLightsOff, true);
});
test('doorway spill golden exposes the opaque-fill failure mode from issue 71', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'lighting-opaque-glow-two-doorways-dark');
assert.ok(scenario);
@@ -234,7 +282,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 34);
assert.equal(GOLDEN_MATRIX_VERSION, 35);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -424,6 +472,9 @@ test('golden overrides fail closed on misspelled fixture references', () => {
assert.throws(() => prepareGoldenFixture({
...base, roomGlow: { 'light-rightt': true },
}), /missing room/);
assert.throws(() => prepareGoldenFixture({
...base, decorOverride: [{ id: 'bad', kind: 'triangle' }],
}), /unknown kind/);
});
test('a light source paints exactly one region: the floor it can see', () => {