fix(plan): write every room state's transparency as colour plus opacity (#746)

`.room { transition: 0.12s }` interpolates a room's colour and its
fill-opacity / stroke-opacity independently, and the visible opacity is their
product. The states without a fill kept their alpha in the colour with the
default opacity 1 (.overlay transparent, .yard rgba 0.14, .outlined rgba
0.06 / 0.55, .picked rgba 0.25), while .styled writes an opaque colour plus
fill-opacity: var(--room-fill-op). On a change between the two on the same
node one half rose while the other fell, and mid-way the room was darker than
at either end. Opening the space settings on a floor with no fill (the dialog
shows "no fill" as its own colour at alpha 0) flashed every room grey for
~0.1 s, 0 -> 0.241 -> 0; cancelling the dialog after a preview, entering and
leaving the plan editor briefly darkened the fill (0.18 -> 0.317 -> 0.06).

Every .room state now writes an opaque colour plus *-opacity, and
transparent only together with a zero opacity. The transition itself,
.styled and the --room-* variables are unchanged; the space card takes the
same styles. The resting paint is the same: the witness records each state's
colour and visible opacity as dev drew them, and screenshots of seven resting
states (View without fill, with fill and borders, plan editor, room picked for
a merge, yard with and without borders, yard in the plan editor) are
pixel-identical to dev outside the plan editor's tool hint, whose text shifts
by a sub-pixel between runs on dev too.

Witness: new demo/smoke_room_fill_transitions.mjs, deterministic. A
MutationObserver pauses the room's transitions at their first frame right
after Lit commits, and the smoke seeks them through 0..120 ms in 15 ms steps.
Red on dev: fill overshoot 0.241 / 0.125 / 0.137 / 0.134 on the four paths,
stroke 0.241 / 0.242 on the first two. A real colour change of the same room
still runs a fill transition (catches `transition: none`).

A card-mod rule that sets only `fill` on an unfilled room now meets
fill-opacity 0; CHANGELOG and STYLING-HOOKS say to set the opacity with the
colour (the values are generated, STYLING-HOOKS §3.3).

Issue: #746
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-10-01 14:09:52 +03:00
parent 7ce3f6629f
commit cd141be938
5 changed files with 284 additions and 4 deletions
+241
View File
@@ -0,0 +1,241 @@
// Smoke (#746): a room's fill and stroke never get darker in transit than at
// either end when the room changes state on the same node.
//
// `.room { transition: 0.12s }` interpolates the colour and `fill-opacity` /
// `stroke-opacity` independently, and the visible opacity is their product.
// When one state keeps its transparency in the colour (`rgba(…, 0.06)` with
// the default opacity 1) and the other in `*-opacity` (an opaque colour with
// 0.18), one half rises while the other falls, and the product in the middle
// is larger than both ends: on dev opening the space settings on a floor with
// no fill flashed every room grey (0 → 0.241 → 0), and entering or leaving
// the plan editor briefly darkened a filled floor (0.18 → 0.317 → 0.06).
//
// The witness is deterministic, not a frame recording. A MutationObserver on
// the room node pauses every CSS transition at its first frame as soon as Lit
// commits the new class or style (a microtask, before any frame), and the
// smoke then seeks each transition through 0…120 ms in steps of 15 ms. The
// result does not depend on how busy the machine is.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
/** Visible opacity may exceed the larger end by this much (rounding). */
const TOLERANCE = 0.005;
/**
* Resting paint of each room state, as dev drew it before #746: colour of the
* visible paint (null when nothing is visible) and its visible opacity
* (colour alpha × `*-opacity`). The fix must not move any of them.
*/
const REST = {
overlay: { fill: [null, 0], stroke: [null, 0] },
styled: { fill: ['rgb(96, 125, 139)', 0.18], stroke: [null, 0] },
outlined: { fill: ['rgb(62, 166, 255)', 0.06], stroke: ['rgb(62, 166, 255)', 0.55] },
picked: { fill: ['rgb(255, 193, 77)', 0.25], stroke: ['rgb(255, 193, 77)', 1] },
yard: { fill: ['rgb(75, 140, 90)', 0.14], stroke: ['rgb(75, 140, 90)', 1] },
};
await page.evaluate(() => {
const c = window.__card;
const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const settle = async (frames = 10, ms = 400) => {
for (let i = 0; i < frames; i++) await frame();
await sleep(ms);
};
const room = () => c.renderRoot.querySelector('[data-hp="room"][data-id="r1"]');
const channels = (color) => /rgba?\(([^)]+)\)/.exec(color)?.[1].split(/[\s,/]+/).filter(Boolean) || null;
const alphaOf = (color) => {
const parts = channels(color);
if (!parts) return 0; // `none`
return parts.length > 3 ? Number(parts[3]) : 1;
};
const rgbOf = (color) => {
const parts = channels(color);
return parts ? `rgb(${parts.slice(0, 3).join(', ')})` : color;
};
const round = (value) => Math.round(value * 10000) / 10000;
/** Visible paint of the room: colour alpha × `*-opacity`, and the colour when it shows. */
const paint = (node) => {
const style = getComputedStyle(node);
const fill = round(alphaOf(style.fill) * Number(style.fillOpacity));
const stroke = round(alphaOf(style.stroke) * Number(style.strokeOpacity));
return {
cls: node.getAttribute('class'),
fill: [fill > 0 ? rgbOf(style.fill) : null, fill],
stroke: [stroke > 0 ? rgbOf(style.stroke) : null, stroke],
};
};
const transitionsOf = (node) => node.getAnimations().filter((animation) => animation instanceof CSSTransition);
/**
* Pause every transition of the node at its first frame. The observer runs
* right after Lit's commit, and `getAnimations()` flushes the style, so the
* transition exists and has not advanced yet; `transitionrun` covers one
* started by anything else.
*/
const freezeOn = (node) => {
const freeze = () => {
for (const animation of transitionsOf(node)) {
if (animation.playState === 'paused') continue;
animation.pause();
animation.currentTime = 0;
}
};
const observer = new MutationObserver(freeze);
observer.observe(node, { attributes: true });
node.addEventListener('transitionrun', freeze);
return () => { observer.disconnect(); node.removeEventListener('transitionrun', freeze); };
};
const close = (a, b) => Math.abs(a - b) <= 0.005;
window.__fillWitness = {
settle,
paint: () => paint(room()),
async rest() { await settle(); return paint(room()); },
/** Run `act`, then seek the room's frozen transitions through their whole 0.12 s. */
async measure(act) {
await settle();
const node = room();
const start = paint(node);
const release = freezeOn(node);
await act();
await settle(4, 80);
release();
const after = room();
const transitions = transitionsOf(after);
const samples = [];
for (let t = 0; t <= 120; t += 15) {
for (const animation of transitions) animation.currentTime = t;
samples.push(paint(after));
}
const frozen = transitions.every((animation) => animation.playState === 'paused');
for (const animation of transitions) animation.finish();
await frame();
const end = paint(after);
const peak = (key) => Math.max(...samples.map((sample) => sample[key][1]));
return {
sameNode: after === node,
from: start.cls,
to: end.cls,
properties: [...new Set(transitions.map((animation) => animation.transitionProperty))].sort(),
frozen,
// the seek spans the real transition: first sample is the old state, last the new one
spansBothEnds: samples.length > 0
&& close(samples[0].fill[1], start.fill[1]) && close(samples[0].stroke[1], start.stroke[1])
&& close(samples.at(-1).fill[1], end.fill[1]) && close(samples.at(-1).stroke[1], end.stroke[1]),
fill: [start.fill[1], peak('fill'), end.fill[1]],
stroke: [start.stroke[1], peak('stroke'), end.stroke[1]],
fillOvershoot: round(peak('fill') - Math.max(start.fill[1], end.fill[1])),
strokeOvershoot: round(peak('stroke') - Math.max(start.stroke[1], end.stroke[1])),
};
},
};
});
const run = (fn) => page.evaluate(fn);
const measured = {};
const rest = {};
// AC1 (1): open «Space settings» on a floor without fill. The dialog shows
// "no fill" as its own colour at alpha 0, so the room goes overlay → styled.
rest.overlay = await run(() => window.__fillWitness.rest());
measured.openSettings = await run(() => window.__fillWitness.measure(
() => window.__hpTest.openSpaceDialog('edit', 'f1')));
// AC1 (2): preview alpha 18 % in the dialog, then cancel it: styled → overlay.
await run(async () => {
const dialog = window.__card.renderRoot.querySelector('[data-hp="dialog"][data-kind="space"]');
// the custom-fill row: its picker holds the default fill colour at alpha 0
const field = [...dialog.querySelectorAll('.hpf-colorfield')]
.find((node) => node.querySelector('hp-color-opacity')?.color?.toLowerCase() === '#607d8b');
if (!field) throw new Error('#746: the space dialog shows no custom-fill row');
await window.__hpTest.input(field.querySelector('input[type="number"]'), '18');
await window.__fillWitness.settle();
});
const preview = await run(() => window.__fillWitness.paint());
measured.cancelSettings = await run(() => window.__fillWitness.measure(async () => {
const T = window.__hpTest;
const dialog = window.__card.renderRoot.querySelector('[data-hp="dialog"][data-kind="space"]');
const { confirm } = await T.close(dialog, { via: 'cancel' });
// the preview made the dialog dirty: discard it the way a person does
if (confirm) {
confirm.querySelector('[data-hp="dialog-confirm"]').click();
await T.settled();
}
}));
const dialogClosed = await run(() => !window.__card.renderRoot.querySelector('[data-hp="dialog"][data-kind="space"]'));
// AC1 (3, 4): enter and leave the plan editor on a filled floor.
await run(async () => {
await window.__hpTest.setServerConfig((cfg) => {
const space = cfg.spaces.find((item) => item.id === 'f1');
space.settings = { ...(space.settings || {}), fill_mode: 'custom' };
});
});
rest.styled = await run(() => window.__fillWitness.rest());
measured.enterPlanEditor = await run(() => window.__fillWitness.measure(() => window.__hpTest.setMode('plan')));
rest.outlined = await run(() => window.__fillWitness.rest());
measured.leavePlanEditor = await run(() => window.__fillWitness.measure(() => window.__hpTest.setMode('view')));
// Control: a real colour change of the same room still animates its fill —
// catches a false fix `transition: none`.
measured.control = await run(() => window.__fillWitness.measure(() => window.__hpTest.setServerConfig((cfg) => {
const space = cfg.spaces.find((item) => item.id === 'f1');
space.settings = { ...space.settings, custom_fill: { c: '#ff9800', a: 0.3 } };
})));
// AC2: the room picked for a merge — a real click of the Merge tool.
await run(async () => {
await window.__hpTest.setMode('plan');
await window.__hpTest.setTool('merge');
await window.__fillWitness.settle();
});
const box = await run(() => {
const rect = window.__card.renderRoot.querySelector('[data-hp="room"][data-id="r1"]').getBoundingClientRect();
return { x: rect.left + rect.width * 0.15, y: rect.top + rect.height * 0.2 };
});
await page.mouse.click(box.x, box.y);
// off the plan: View's hover paints the room in the accent colour
await page.mouse.move(2, 2);
rest.picked = await run(() => window.__fillWitness.rest());
// AC2: a space without a backdrop draws its rooms as `yard`.
await run(async () => {
await window.__hpTest.setMode('view');
await window.__hpTest.setServerConfig((cfg) => {
const space = cfg.spaces.find((item) => item.id === 'f1');
delete space.plan_url;
// without a backdrop the borders default to on (`styled`); the bare state is wanted here
space.settings = { show_borders: false };
});
});
rest.yard = await run(() => window.__fillWitness.rest());
const STATES = {
openSettings: [/^room overlay$/, /\bstyled filled\b/],
cancelSettings: [/\bstyled filled\b/, /^room overlay$/],
enterPlanEditor: [/\bstyled filled\b/, /\boutlined\b/],
leavePlanEditor: [/\boutlined\b/, /\bstyled filled\b/],
control: [/\bstyled filled\b/, /\bstyled filled\b/],
};
for (const [name, result] of Object.entries(measured)) {
const [from, to] = STATES[name];
check(`${name}: fixture`, from.test(result.from) && to.test(result.to) || [result.from, result.to]);
check(`${name}: the same room node changes state`, result.sameNode);
check(`${name}: a fill transition runs and is frozen at its first frame`,
result.properties.includes('fill') && result.frozen && result.spansBothEnds || result);
check(`${name}: fill is no darker in transit than at either end`,
result.fillOvershoot <= TOLERANCE || result.fill);
check(`${name}: stroke is no darker in transit than at either end`,
result.strokeOvershoot <= TOLERANCE || result.stroke);
}
check('cancelSettings: the preview showed alpha 0.18', preview.fill[1], 0.18);
check('cancelSettings: the dialog closed', dialogClosed);
check('picked: the Merge click picked the room', /\bpicked\b/.test(rest.picked.cls) || rest.picked.cls);
check('yard: the room is drawn without a backdrop', /^room yard$/.test(rest.yard.cls) || rest.yard.cls);
for (const [state, expected] of Object.entries(REST)) {
for (const channel of ['fill', 'stroke']) {
const [color, opacity] = rest[state][channel];
check(`${state} at rest: ${channel} colour and visible opacity unchanged`,
color === expected[channel][0] && Math.abs(opacity - expected[channel][1]) <= TOLERANCE
|| rest[state][channel]);
}
}
await finish(browser, { measured, rest, preview });
+6
View File
@@ -20,6 +20,12 @@
slide. A real colour change on the same floor — a light turning on, a new
temperature, hover — still fades smoothly
([#742](https://github.com/Matysh/houseplan-card/issues/742)).
- Rooms no longer flash grey or briefly darken when the space settings open or
the plan editor is entered or left. If you recolour room shapes with card-mod,
set `fill-opacity` / `stroke-opacity` together with `fill` / `stroke`: a room
now keeps its transparency there in every state, and a room without a fill
or border has them at 0
([#746](https://github.com/Matysh/houseplan-card/issues/746)).
## v1.79.0-beta.1 — 2026-09-30
+7
View File
@@ -27,6 +27,13 @@
сдвигом плана. Настоящая смена цвета на том же этаже — включился свет,
сменилась температура, наведение — по-прежнему идёт плавно
([#742](https://github.com/Matysh/houseplan-card/issues/742)).
- Комнаты больше не вспыхивают серым и не темнеют на мгновение при открытии
настроек пространства, а также при входе в редактор плана и выходе из него.
Если вы перекрашиваете комнаты правилами card-mod, задавайте вместе с `fill` /
`stroke` и `fill-opacity` / `stroke-opacity`: прозрачность комнаты теперь во
всех состояниях хранится в них, а у комнаты без заливки или границы они
равны 0
([#746](https://github.com/Matysh/houseplan-card/issues/746)).
## v1.79.0-beta.1 — 2026-09-30
+6
View File
@@ -163,6 +163,12 @@ the attributes are visible on its service ghost in the Device editor.
On a room shape: `.filled` (a fill mode is painting it), `.styled`,
`.overlay` / `.yard` (drawn over a picture / on bare canvas).
A room shape keeps its transparency in `fill-opacity` / `stroke-opacity`,
never in the colour: every state is an opaque colour plus an opacity, and paint
that is off has the opacity `0` (#746). A rule that recolours a room therefore
sets the opacity together with `fill` / `stroke`. The values themselves are
generated (§3.3).
### 3.3 Explicitly NOT the contract
These exist in the DOM and **will** change without notice. Do not build on
+24 -4
View File
@@ -403,9 +403,22 @@ export const planStyles = css`
stroke-linecap: round;
}
.stage.mode-view .room { cursor: pointer; }
/* #746: every state of a room writes its transparency the way .styled
does — an opaque colour plus fill-opacity / stroke-opacity, and
transparent only together with a zero *-opacity. The transition above
interpolates the colour and the opacity independently, and what you
see is their product: when one state kept its alpha in the colour
(rgba(…, 0.06), opacity 1) and the other in the opacity (an opaque
colour, 0.18), one half rose while the other fell, and mid-way the
room was darker than at either end — opening the space settings on a
floor with no fill flashed every room grey, entering or leaving the
plan editor darkened a filled floor. The resting paint is the same.
Witness: demo/smoke_room_fill_transitions.mjs. */
.room.overlay {
fill: transparent;
fill-opacity: 0;
stroke: transparent;
stroke-opacity: 0;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
}
:host([data-pointer-hover]) .stage.mode-view .room.overlay:not(.styled):hover {
@@ -413,8 +426,10 @@ export const planStyles = css`
stroke-opacity: 1;
}
.room.yard {
fill: rgba(75, 140, 90, 0.14);
fill: rgb(75, 140, 90);
fill-opacity: 0.14;
stroke: #4b8c5a;
stroke-opacity: 1;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
}
:host([data-pointer-hover]) .stage.mode-view .room.yard:not(.styled):hover {
@@ -1229,15 +1244,20 @@ export const planStyles = css`
outline: 2px solid var(--hp-accent);
outline-offset: -2px;
}
/* #746: opaque colour plus *-opacity, like every .room state (see .room.overlay) */
.room.outlined {
stroke: rgba(62, 166, 255, 0.55);
fill: rgba(62, 166, 255, 0.06);
stroke: rgb(62, 166, 255);
stroke-opacity: 0.55;
fill: rgb(62, 166, 255);
fill-opacity: 0.06;
}
/* AFTER .outlined: same specificity — source order decides (gotcha x4) */
.room.picked {
stroke: #ffc14d;
stroke-opacity: 1;
stroke-width: calc(3px * var(--hp-cell-visual-scale, 1));
fill: rgba(255, 193, 77, 0.25);
fill: rgb(255, 193, 77);
fill-opacity: 0.25;
}
/* Owner 2026-08-04: the grid is a HINT, not content — at full strength the
dots argued with the plan on white paper. Both levels are muted, the