From cd141be938622f4604be2eb96b1bd5a650dbca2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:09:52 +0300 Subject: [PATCH] fix(plan): write every room state's transparency as colour plus opacity (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.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 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- demo/smoke_room_fill_transitions.mjs | 241 +++++++++++++++++++++++++++ docs/CHANGELOG.md | 6 + docs/CHANGELOG.ru.md | 7 + docs/STYLING-HOOKS.md | 6 + src/styles/plan.styles.ts | 28 +++- 5 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 demo/smoke_room_fill_transitions.mjs diff --git a/demo/smoke_room_fill_transitions.mjs b/demo/smoke_room_fill_transitions.mjs new file mode 100644 index 00000000..62d923b9 --- /dev/null +++ b/demo/smoke_room_fill_transitions.mjs @@ -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 }); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2bece24a..0af43eaa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 75fba41b..70729c9c 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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 diff --git a/docs/STYLING-HOOKS.md b/docs/STYLING-HOOKS.md index 7092c2cd..f42b5b10 100644 --- a/docs/STYLING-HOOKS.md +++ b/docs/STYLING-HOOKS.md @@ -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 diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index 1ce29fba..e227905a 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -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