mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
fix(led): polish glow and interactions (#784)
Issue: #784 User-Visible: yes
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
* strips that are on: `--size=10x5` (10 strips × 5 points) or `--size=50x50`
|
||||
* (50 strips × 50 points); `--size=none` is the same build and plan without
|
||||
* strips. No icon is added: the converted devices keep their ids, only their
|
||||
* entity becomes a light. Light radius 50 cm (the strip default), Glow on,
|
||||
* entity becomes a light. Light radius 30 cm (the strip default), Glow on,
|
||||
* viewport 1440×1000, DPR 1, reduced motion. `--warmups` (≥1) samples are
|
||||
* discarded, then `--samples` (≥7) are judged. Every sample is cold: a new
|
||||
* browser with an empty cache mounts a new card; the warm metrics run in the
|
||||
|
||||
@@ -432,7 +432,7 @@ measures the LED strips on the `large-house-v1` fixture: on every floor 10 or 50
|
||||
existing devices become lights shown as strips that are on — `10x5` (10 strips
|
||||
× 5 points) or `50x50` — without adding a device or an icon
|
||||
(`performance/led-strips-fixture.mjs`); `none` is the same build and plan
|
||||
without strips. Glow on, strip radius 50 cm, viewport 1440×1000, DPR 1,
|
||||
without strips. Glow on, strip radius 30 cm, viewport 1440×1000, DPR 1,
|
||||
reduced motion; every sample is cold (a new browser and card), seven samples
|
||||
after one warm-up. The runner fails on its own against
|
||||
`budgets-led-strips.json` — the ТЗ table, median and p95: `firstStableRenderMs`
|
||||
@@ -456,4 +456,3 @@ strips, so there is no relative comparison; the zero-LED View remains judged
|
||||
by the relative `large-house-interaction-v1` profile. Exact-SHA Linux output
|
||||
of the full performance workflow (`led-strips` matrix entry) is the gate
|
||||
evidence; a local report is diagnostic.
|
||||
|
||||
|
||||
@@ -84,7 +84,42 @@ const first = await strips();
|
||||
check('Esc finished one unbound strip', first.length === 1 && first[0].marker === null
|
||||
&& JSON.stringify(first[0].points) === JSON.stringify([[0.2, 0.2], [0.4, 0.2]]));
|
||||
check('the device picker opened', await evaluate(() => !!window.__card.shadowRoot.querySelector('hp-dialog[data-kind="led-picker"]')));
|
||||
await page.setViewportSize({ width: 1000, height: 240 });
|
||||
await page.waitForTimeout(100);
|
||||
// Keep the demo registry deterministic for every smoke. A long picker is a
|
||||
// presentation condition, so clone the product-rendered row in this isolated
|
||||
// page instead of mutating the authoritative HA registry snapshot.
|
||||
await evaluate(() => {
|
||||
const list = window.__card.shadowRoot.querySelector('.led-picker');
|
||||
const template = list.querySelector('.led-pick');
|
||||
for (let i = 0; i < 18; i += 1) {
|
||||
const row = template?.cloneNode(true) || document.createElement('button');
|
||||
row.className = 'btn ghost led-pick';
|
||||
row.removeAttribute('data-led-pick');
|
||||
row.disabled = true;
|
||||
row.textContent = `Picker light ${i + 1}`;
|
||||
list.append(row);
|
||||
}
|
||||
});
|
||||
const pickerBefore = await evaluate(() => {
|
||||
const list = window.__card.shadowRoot.querySelector('.led-picker');
|
||||
const rect = list.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height,
|
||||
scrollTop: list.scrollTop, scrollHeight: list.scrollHeight, clientHeight: list.clientHeight,
|
||||
zoom: window.__card._zoom };
|
||||
});
|
||||
check('the device picker owns a bounded scrolling list', pickerBefore.scrollHeight > pickerBefore.clientHeight, true);
|
||||
await page.mouse.move(pickerBefore.x + pickerBefore.w / 2, pickerBefore.y + pickerBefore.h / 2);
|
||||
await page.mouse.wheel(0, 260);
|
||||
await page.waitForTimeout(250);
|
||||
const pickerAfter = await evaluate(() => ({
|
||||
scrollTop: window.__card.shadowRoot.querySelector('.led-picker').scrollTop,
|
||||
zoom: window.__card._zoom,
|
||||
}));
|
||||
check('wheel scrolls the device picker', pickerAfter.scrollTop > pickerBefore.scrollTop, true);
|
||||
check('wheel over the picker does not zoom the plan', pickerAfter.zoom, pickerBefore.zoom);
|
||||
await page.click('[data-led-action="later"]');
|
||||
await page.setViewportSize({ width: 1000, height: 820 });
|
||||
await page.waitForTimeout(200);
|
||||
check('Later keeps the unbound strip, selected with its tray', await evaluate(() => {
|
||||
const c = window.__card;
|
||||
|
||||
@@ -129,8 +129,17 @@ const calls = await evaluate(async () => {
|
||||
const hit = await evaluate(() => { const r = window.__card.shadowRoot.querySelector('.led-hit').getBoundingClientRect(); return [r.x + r.width * 0.25, r.y + 2]; });
|
||||
const box = await evaluate(() => { const p = window.__card.shadowRoot.querySelector('.led-hit'); const len = p.getTotalLength(); const pt = p.getPointAtLength(len * 0.3); const m = p.getScreenCTM(); return [pt.x * m.a + m.e, pt.y * m.d + m.f]; });
|
||||
await page.mouse.click(box[0], box[1]);
|
||||
await settle();
|
||||
await page.waitForFunction(() => window.__card.hass.states['light.ceiling'].state === 'off');
|
||||
check('a click on the stripe toggles once', await evaluate(() => window.__ledToggle.length), calls + 1);
|
||||
check('turning off keeps the field for the fade', await evaluate(() =>
|
||||
window.__card.shadowRoot.querySelector('[data-led-field]')?.getAttribute('data-led-phase')), 'leaving');
|
||||
check('a pointer click leaves no selection outline', await evaluate(() => {
|
||||
const root = window.__card.shadowRoot;
|
||||
const target = root.querySelector('.led-hit');
|
||||
return !target.matches(':focus-visible')
|
||||
&& getComputedStyle(root.querySelector('.led-focus')).stroke === 'rgba(0, 0, 0, 0)';
|
||||
}), true);
|
||||
await page.waitForTimeout(600);
|
||||
void hit;
|
||||
check('off: no field, white core', JSON.stringify([await stripe('d_light1'), await field()]),
|
||||
JSON.stringify([{ state: 'off', core: '#FFFFFF', dash: null }, 0]));
|
||||
@@ -152,7 +161,15 @@ check('a pan along the stripe calls no service', await ceiling(), 'off');
|
||||
const [x, y] = await along(0.6);
|
||||
await page.mouse.click(x, y);
|
||||
}
|
||||
await settle();
|
||||
await page.waitForFunction(() => window.__card.hass.states['light.ceiling'].state === 'on');
|
||||
const fadeInOpacity = await evaluate(() => Number(getComputedStyle(
|
||||
window.__card.shadowRoot.querySelector('[data-led-field]'),
|
||||
).opacity));
|
||||
check('turning on starts below full opacity', fadeInOpacity < 0.95, true);
|
||||
await page.waitForTimeout(600);
|
||||
check('turning on reaches full opacity smoothly', await evaluate(() => Number(getComputedStyle(
|
||||
window.__card.shadowRoot.querySelector('[data-led-field]'),
|
||||
).opacity) > 0.99), true);
|
||||
check('the next clean click works at once', await ceiling(), 'on');
|
||||
{
|
||||
const [x, y] = await along(0.6);
|
||||
@@ -225,6 +242,23 @@ check('static live_states:true + light_pools:true — white core and the field,
|
||||
JSON.stringify({ state: 'on', white: true, field: true, passive: true, badge: true }));
|
||||
check('a click on any static stripe toggles nothing', await evaluate(() => window.__card.hass.states['light.ceiling'].state), 'on');
|
||||
|
||||
await evaluate(() => window.__hpTest.setMode('devices'));
|
||||
await page.locator('.led-select-hit[data-led-select="ceiling"]').dispatchEvent('click');
|
||||
await page.waitForTimeout(100);
|
||||
await page.click('[data-led-action="settings"]');
|
||||
await page.waitForTimeout(100);
|
||||
const radiusFocus = await evaluate(() => {
|
||||
const input = window.__card.shadowRoot.querySelector('#marker-glow-radius');
|
||||
const unit = input?.closest('.hpf-unit');
|
||||
input?.focus();
|
||||
return input && unit ? {
|
||||
input: getComputedStyle(input).outlineStyle,
|
||||
wrapper: getComputedStyle(unit).outlineStyle,
|
||||
} : null;
|
||||
});
|
||||
check('the Glow radius has one wrapper focus outline', JSON.stringify(radiusFocus),
|
||||
JSON.stringify({ input: 'none', wrapper: 'solid' }));
|
||||
|
||||
// The device dialog offers «Show as LED strip» before the tool is loaded:
|
||||
// a static section (no dialog shift), the press loads the tool and starts
|
||||
// drawing for this same marker after the dialog's own close path.
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- LED strips now cast a smooth, continuous Glow field without gradient bands
|
||||
or cuts at corners and fade in and out instead of switching abruptly. Their
|
||||
default Glow radius is now 30 cm. The device picker scrolls without zooming
|
||||
the plan, the radius field has one focus outline, and clicking a strip no
|
||||
longer leaves a selection outline while keyboard focus remains visible
|
||||
([#784](https://github.com/Matysh/houseplan-card/issues/784)).
|
||||
|
||||
## v1.79.0-beta.3 — 2026-10-03
|
||||
|
||||
- LED strips: in the Devices editor, the new «LED strip» tool next to «Add»
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- LED-ленты теперь дают мягкое непрерывное свечение без полос градиента и
|
||||
срезов на углах и плавно загораются и гаснут. Радиус свечения по умолчанию
|
||||
изменён на 30 см. Список выбора устройства прокручивается, не меняя масштаб
|
||||
плана; у поля радиуса осталась одна обводка фокуса, а клик по ленте больше не
|
||||
оставляет обводку выделения — видимый клавиатурный фокус сохранён
|
||||
([#784](https://github.com/Matysh/houseplan-card/issues/784)).
|
||||
|
||||
## v1.79.0-beta.3 — 2026-10-03
|
||||
|
||||
- LED-ленты: в редакторе устройств рядом с «Добавить» появился инструмент
|
||||
|
||||
+1
-1
@@ -418,7 +418,7 @@ its anchor. Colour, brightness, role and availability come from the same
|
||||
`resolveGlowCandidates` / `resolveGlowAppearance` path as every other source;
|
||||
only the geometry differs:
|
||||
|
||||
- **Radius.** 50 cm by default, independent of `settings.glow_radius_cm`; the
|
||||
- **Radius.** 30 cm by default, independent of `settings.glow_radius_cm`; the
|
||||
marker's personal `glow_radius_cm` wins.
|
||||
- **Field.** A continuous band along every segment with round free ends: grey
|
||||
luminance bands of the shared `GLOW_FALLOFF`, one piece per stretch, blended
|
||||
|
||||
+1
-1
@@ -1059,7 +1059,7 @@ editor** — there is no need to switch to the Plan editor.
|
||||
|
||||
Binding creates no new Home Assistant device: the same marker, light
|
||||
settings, tap actions and badge — drawn at the middle of the strip's length. The strip glows when the device does: with
|
||||
Glow on, a white core and a soft band of colour 50 cm wide by default (or the
|
||||
Glow on, a white core and a soft band of colour 30 cm wide by default (or the
|
||||
device's own radius); without Glow the core takes the light's colour. Light
|
||||
does not pass through walls or closed doors. In View the whole strip is one
|
||||
tap target, like an icon. Every finished shape change and every switch of the
|
||||
|
||||
@@ -1156,7 +1156,7 @@ binding tombstone не вырезает её из живого устройст
|
||||
Привязка не создаёт нового устройства в Home Assistant: тот же маркер, те же
|
||||
настройки света, действия по нажатию и бейдж — он стоит в середине длины ленты. Лента светится, когда светится
|
||||
устройство: при включённом Glow — белое ядро и мягкая цветная полоса света
|
||||
радиусом 50 см (или персональный радиус устройства), без Glow — ядро цвета
|
||||
радиусом 30 см (или персональный радиус устройства), без Glow — ядро цвета
|
||||
источника. Свет не проходит сквозь стены и закрытые двери. В просмотре лента
|
||||
— одна цель нажатия по всей длине, как значок. Каждое законченное изменение
|
||||
формы и смена представления — один шаг **Отменить/Повторить**.
|
||||
|
||||
@@ -43,7 +43,7 @@ behaviours, not of positions.
|
||||
| On with Glow: white core + coloured field; without Glow: core in the source colour, no field | `ledStripView` + `resolveGlowAppearance` | smoke `smoke_led_strip_glow.mjs` |
|
||||
| Glow is the space/room switch, independent of `fill_mode` | `glowFor(room)` | `lighting-led-strip-glow-dark` uses `fill_mode: none` |
|
||||
| Per-piece offset: t/2 on a thick face into free floor, 0 on free floor and zero walls; continuous transition | `visibleStripPath` | unit `test/led-strip-geometry.test.mjs` (AC8) |
|
||||
| Field 50 cm by default, own `glow_radius_cm` wins; round free ends; no seams or doubled brightness at corners/closure | `ledFrame`, `led-strip-field` (`lighten` in one group) | unit `test/led-strip-runtime.test.mjs`; reference pair |
|
||||
| Field 30 cm by default, own `glow_radius_cm` wins; round free ends; no seams, bands or doubled brightness at corners/closure | `ledFrame`, one continuous path through the unioned visibility clip in `led-strip-field` | unit `test/led-strip-runtime.test.mjs`; reference pair |
|
||||
| Shared `glowAlpha` / `GLOW_FALLOFF` / `GLOW_FADE_MS` | field bands from `falloffAt` | unit `test/led-strip-runtime.test.mjs` |
|
||||
| Field under icons, badges and labels; icons not tinted | glow layer below the device layer | reference pair (designer tinting deliberately not reproduced) |
|
||||
|
||||
@@ -69,4 +69,4 @@ behaviours, not of positions.
|
||||
the default `icon_size` and the band edge is the shared Glow edge.
|
||||
- Icons and labels are not tinted by the field.
|
||||
- The mockup's "half the shared radius" and "always coloured core" are replaced
|
||||
by 50 cm and the white core under Glow (owner's decision in #780).
|
||||
by 30 cm (#784) and the white core under Glow (owner's decision in #780).
|
||||
|
||||
@@ -11,7 +11,7 @@ with the frames Led On (626:35) and Led Off (626:53).
|
||||
**The contract is the ТЗ in the body of
|
||||
[#780](https://github.com/Matysh/houseplan-card/issues/780)**, not the archived
|
||||
`source/TZ-issue-662-LED-strips.md`. Where they differ the issue wins — notably
|
||||
the 50 cm default radius, the white core under Glow, editing in the Devices
|
||||
the 30 cm default radius (#784), the white core under Glow, editing in the Devices
|
||||
editor and the product thicknesses 0.08/0.12 D instead of the mockup's pixel
|
||||
sizes and blur. These files are documentation only; nothing here reaches the
|
||||
runtime bundle.
|
||||
|
||||
@@ -14268,10 +14268,10 @@ const MUTANT_DEFINITIONS = [
|
||||
id: 'led-default-radius-shared',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/led-strip-runtime.test.mjs',
|
||||
because: "#780 ТЗ §3: the field radius is 50 cm by default, independent of the shared radius of ordinary sources",
|
||||
because: "#784: the field radius is 30 cm by default, independent of the shared radius of ordinary sources",
|
||||
patches: [{
|
||||
file: 'src/led-strip-geometry.ts',
|
||||
find: "export const LED_DEFAULT_RADIUS_CM = 50;",
|
||||
find: "export const LED_DEFAULT_RADIUS_CM = 30;",
|
||||
replace: "export const LED_DEFAULT_RADIUS_CM = 360;",
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -783,8 +783,10 @@ export class LedStripEditor {
|
||||
.sort((a, b) => Number(isLight(b)) - Number(isLight(a)) || a.name.localeCompare(b.name));
|
||||
const close = () => { this.picker = null; this.host.requestUpdate(); };
|
||||
return html`<hp-dialog .hass=${this.host.hass} data-kind="led-picker" .title=${this.t('led.pick_title')}
|
||||
icon="mdi:led-strip-variant" @hp-close=${close}>
|
||||
<div class="body led-picker" style="display:flex;flex-direction:column;gap:4px">
|
||||
icon="mdi:led-strip-variant" flex-content @wheel=${(e: WheelEvent) => e.stopPropagation()}
|
||||
@hp-close=${close}>
|
||||
<div class="body led-picker"
|
||||
style="display:flex;flex-direction:column;gap:4px;min-height:0;max-height:min(60dvh,520px);overflow-y:auto;overscroll-behavior:contain">
|
||||
${devices.length ? devices.map((d) => {
|
||||
const owner = ownerOf(this.host._serverCfg, d.id);
|
||||
const taken = !!owner && owner.strip.id !== stripId;
|
||||
|
||||
+97
-35
@@ -5,19 +5,19 @@
|
||||
* card has a light scene — the static card with `light_pools: false` never
|
||||
* loads it (ТЗ §13.1).
|
||||
*
|
||||
* The field is the exact distance field of the strip with the shared falloff:
|
||||
* every piece of the strip paints opaque grey bands of a luminance mask (round
|
||||
* caps and joins, so one piece never doubles itself), pieces meet through
|
||||
* `mix-blend-mode: lighten` — the maximum, i.e. the nearest piece — and each
|
||||
* piece is clipped to the floor its own emitters can see. A hidden part never
|
||||
* lights through another part's visibility, a closed strip has no seam and a
|
||||
* corner no double brightness.
|
||||
* The field is the distance field of one continuous strip path with the shared
|
||||
* falloff. Visibility remains sampled in bounded pieces, but their visible
|
||||
* regions are unioned into one clip before the continuous path is painted.
|
||||
* That separation keeps walls opaque without exposing piece boundaries in the
|
||||
* gradient at straight cuts or corners.
|
||||
*/
|
||||
import { noChange, nothing, svg, type TemplateResult } from 'lit';
|
||||
import { Directive, directive, type PartInfo } from 'lit/directive.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import {
|
||||
GLOW_FALLOFF, type LightBarrierScene, type LightRoomPolygon,
|
||||
createGlowRuntimeState, disposeGlowRuntime, forgetGlowSpace, GLOW_FALLOFF,
|
||||
pruneGlowSources, transitionGlowSource, type GlowRuntimeHost,
|
||||
type GlowRuntimeState, type LightBarrierScene, type LightRoomPolygon,
|
||||
} from './glow-scene';
|
||||
import { visibilityPolygon } from './light-visibility';
|
||||
import {
|
||||
@@ -45,11 +45,11 @@ const memo = directive(Memo);
|
||||
export const LED_FIELD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
|
||||
|
||||
/**
|
||||
* Bands of the luminance field. Twelve keep the midpoint error at the r/2
|
||||
* visual acceptance point below 10%, without multiplying every visibility
|
||||
* piece into sixteen SVG paint nodes.
|
||||
* Bands of the luminance field. The field is painted once per strip rather
|
||||
* than once per visibility piece, so 48 steps remain bounded while keeping
|
||||
* neighbouring alpha levels below the threshold that showed as rings.
|
||||
*/
|
||||
export const LED_FIELD_BANDS = 12;
|
||||
export const LED_FIELD_BANDS = 48;
|
||||
|
||||
const pts = (points: readonly number[][]): Pt[] => points.map((p) => [p[0], p[1]] as Pt);
|
||||
|
||||
@@ -60,6 +60,8 @@ interface FieldPiece {
|
||||
}
|
||||
|
||||
interface FieldGeometry {
|
||||
/** One continuous path; never split at visibility/cache boundaries. */
|
||||
d: string;
|
||||
pieces: FieldPiece[];
|
||||
box: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
@@ -184,6 +186,9 @@ export function buildFieldGeometry(input: {
|
||||
const path = compactPoints(pts(input.points));
|
||||
if (path.length < 2 || !(input.radius > 0)) return null;
|
||||
const r = input.radius;
|
||||
const closed = isClosedStrip(path);
|
||||
const visiblePath = closed ? path.slice(0, -1) : path;
|
||||
const d = `${visiblePath.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' ')}${closed ? ' Z' : ''}`;
|
||||
// Consecutive pieces of at most r along the polyline.
|
||||
const runs: Pt[][] = [];
|
||||
let run: Pt[] = [path[0]];
|
||||
@@ -218,14 +223,14 @@ export function buildFieldGeometry(input: {
|
||||
let clip: string[] | null;
|
||||
try { clip = fans(emitters, r, input.scene); } catch { continue; } // fail-dark for this piece
|
||||
if (clip && !clip.length) continue;
|
||||
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${p[0]} ${p[1]}`).join(' '), clip });
|
||||
pieces.push({ d: piece.map((p, k) => `${k ? 'L' : 'M'}${coord(p[0])} ${coord(p[1])}`).join(' '), clip });
|
||||
for (const p of piece) {
|
||||
minX = Math.min(minX, p[0]); minY = Math.min(minY, p[1]);
|
||||
maxX = Math.max(maxX, p[0]); maxY = Math.max(maxY, p[1]);
|
||||
}
|
||||
}
|
||||
if (!pieces.length) return null;
|
||||
return { pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } };
|
||||
return { d, pieces, box: { x: minX - r, y: minY - r, w: maxX - minX + 2 * r, h: maxY - minY + 2 * r } };
|
||||
}
|
||||
|
||||
/** The shared falloff at a relative distance 0…1 (GLOW_FALLOFF, linear between stops). */
|
||||
@@ -252,13 +257,61 @@ export interface LedFieldInput {
|
||||
spaceId: string;
|
||||
/** The card that owns the bounded cache (one per card, gone with it). */
|
||||
owner: object;
|
||||
requestUpdate: () => void;
|
||||
isConnected: () => boolean;
|
||||
reducedMotion?: () => boolean;
|
||||
}
|
||||
|
||||
const fieldCaches = new WeakMap<object, LedFieldCache>();
|
||||
interface FieldLifecycle {
|
||||
state: GlowRuntimeState;
|
||||
host: GlowRuntimeHost;
|
||||
callbacks: Pick<LedFieldInput, 'requestUpdate' | 'isConnected' | 'reducedMotion'>;
|
||||
spaceId: string;
|
||||
}
|
||||
const fieldLifecycles = new WeakMap<object, FieldLifecycle>();
|
||||
|
||||
function fieldLifecycle(input: LedFieldInput): FieldLifecycle {
|
||||
let lifecycle = fieldLifecycles.get(input.owner);
|
||||
if (!lifecycle) {
|
||||
const callbacks = {
|
||||
requestUpdate: input.requestUpdate,
|
||||
isConnected: input.isConnected,
|
||||
reducedMotion: input.reducedMotion,
|
||||
};
|
||||
const host: GlowRuntimeHost = {
|
||||
window: () => window,
|
||||
isConnected: () => callbacks.isConnected(),
|
||||
requestUpdate: () => callbacks.requestUpdate(),
|
||||
reducedMotion: () => callbacks.reducedMotion?.()
|
||||
?? window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
?? false,
|
||||
};
|
||||
lifecycle = { state: createGlowRuntimeState(), host, callbacks, spaceId: '' };
|
||||
fieldLifecycles.set(input.owner, lifecycle);
|
||||
} else {
|
||||
lifecycle.callbacks.requestUpdate = input.requestUpdate;
|
||||
lifecycle.callbacks.isConnected = input.isConnected;
|
||||
lifecycle.callbacks.reducedMotion = input.reducedMotion;
|
||||
}
|
||||
if (lifecycle.spaceId && lifecycle.spaceId !== input.spaceId) {
|
||||
forgetGlowSpace(lifecycle.state, lifecycle.host, lifecycle.spaceId);
|
||||
}
|
||||
lifecycle.spaceId = input.spaceId;
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
/** Disconnect (ТЗ §13.2): no retained entry of this owner. */
|
||||
export function releaseLedField(owner: object): void {
|
||||
fieldCaches.get(owner)?.clear();
|
||||
fieldCaches.delete(owner);
|
||||
const lifecycle = fieldLifecycles.get(owner);
|
||||
if (lifecycle) disposeGlowRuntime(lifecycle.state, lifecycle.host);
|
||||
fieldLifecycles.delete(owner);
|
||||
}
|
||||
/** Keep rendering while an off transition still owns a DOM node. */
|
||||
export function hasLedField(owner: object): boolean {
|
||||
return !!fieldLifecycles.get(owner)?.state.renderedSources.size;
|
||||
}
|
||||
/** The performance witness: what this owner retains right now. */
|
||||
export function ledFieldStats(owner: object): { visibility: number; sources: number; recomputes: number } {
|
||||
@@ -284,9 +337,18 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
if (!input.scene) return svg`` as unknown as TemplateResult;
|
||||
const cache = ledFieldCache(input.owner);
|
||||
cache.forSpace(input.spaceId);
|
||||
const lifecycle = fieldLifecycle(input);
|
||||
const scene = input.scene;
|
||||
const seen = new Set<string>();
|
||||
const fields = input.views.flatMap((view) => {
|
||||
if (!view.glow || view.state === 'unavailable') return [];
|
||||
const lifecycleKey = `${input.spaceId}|${view.strip.id}`;
|
||||
seen.add(lifecycleKey);
|
||||
const active = view.glow && view.state === 'on' && !!view.appearance;
|
||||
const transition = transitionGlowSource(lifecycle.state, lifecycle.host, lifecycleKey, active);
|
||||
if (!transition) return [];
|
||||
if (active && view.appearance) lifecycle.state.lastAppearance.set(lifecycleKey, view.appearance);
|
||||
const appearance = active ? view.appearance : lifecycle.state.lastAppearance.get(lifecycleKey) ?? null;
|
||||
if (!appearance) return [];
|
||||
const key = `${view.strip.id}|${pointsKey(view.strip.points)}|${view.radius.toFixed(5)}|${scene.fingerprint}`;
|
||||
const geometry = cache.read(key, () => buildFieldGeometry({
|
||||
points: view.strip.points,
|
||||
@@ -296,8 +358,9 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
faces: input.faces,
|
||||
spaceId: input.spaceId,
|
||||
}));
|
||||
return geometry ? [{ view, geometry }] : [];
|
||||
return geometry ? [{ view: { ...view, appearance }, geometry, transition }] : [];
|
||||
});
|
||||
pruneGlowSources(lifecycle.state, lifecycle.host, input.spaceId, seen);
|
||||
if (!fields.length) return svg`` as unknown as TemplateResult;
|
||||
const bands = Array.from({ length: LED_FIELD_BANDS }, (_, k) => {
|
||||
const outer = 1 - k / LED_FIELD_BANDS;
|
||||
@@ -309,34 +372,33 @@ export function renderLedField(input: LedFieldInput): TemplateResult {
|
||||
data-led-cache="${cache.size}" data-led-recomputes="${cache.recomputes}">
|
||||
<defs><clipPath id="hp-led-floor">${fieldFloor(scene).map((d) => svg`<path d="${d}"></path>`)}</clipPath></defs>
|
||||
<g clip-path="url(#hp-led-floor)">
|
||||
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry }) => {
|
||||
${repeat(fields, ({ view }) => view.strip.id, ({ view, geometry, transition }) => {
|
||||
const id = `${cache.id(view.strip.id)}`;
|
||||
const r = view.radius;
|
||||
const on = view.state === 'on' && !!view.appearance;
|
||||
const box = geometry.box;
|
||||
const closed = isClosedStrip(pts(view.strip.points));
|
||||
const clipped = geometry.pieces.flatMap((piece, k): Array<FieldPiece & { clip: string[]; clipId: number }> => piece.clip
|
||||
? [{ ...piece, clip: piece.clip, clipId: k }] : []);
|
||||
const free = geometry.pieces.filter((piece) => !piece.clip).map((piece) => piece.d).join(' ');
|
||||
const paint = [...clipped, ...(free ? [{ d: free, clip: null, clipId: -1 }] : [])];
|
||||
return memo([geometry, on, view.appearance?.c, view.appearance?.alpha, r, id], () => svg`<g class="glow-spot led-field ${on ? '' : 'is-leaving'}" data-led-field="${view.strip.id}"
|
||||
data-pieces="${geometry.pieces.length}" data-closed="${closed ? 'true' : 'false'}">
|
||||
const clipped = geometry.pieces.filter((piece): piece is FieldPiece & { clip: string[] } => !!piece.clip);
|
||||
const free = geometry.pieces.filter((piece) => !piece.clip);
|
||||
const visibilityClip = clipped.length > 0;
|
||||
return memo([geometry, transition.entering, transition.leaving, view.appearance.c,
|
||||
view.appearance.alpha, r, id], () => svg`<g
|
||||
class="glow-spot led-field ${transition.entering ? 'is-entering' : ''} ${transition.leaving ? 'is-leaving' : ''}"
|
||||
data-led-field="${view.strip.id}" data-led-phase="${transition.entering ? 'entering' : transition.leaving ? 'leaving' : 'visible'}"
|
||||
data-pieces="${geometry.pieces.length}" data-bands="${LED_FIELD_BANDS}"
|
||||
data-closed="${closed ? 'true' : 'false'}">
|
||||
<defs>
|
||||
${clipped.map((piece) => svg`<clipPath id="hp-led-clip-${id}-${piece.clipId}">
|
||||
${''/* Subpaths of one path have the same union semantics in a clipPath,
|
||||
without one DOM node per emitter. */}
|
||||
<path d="${piece.clip.join(' ')}"></path>
|
||||
</clipPath>`)}
|
||||
${visibilityClip ? svg`<clipPath id="hp-led-visible-${id}">
|
||||
${free.map((piece) => svg`<path d="${piece.d}" fill="none" stroke="white"
|
||||
stroke-width="${2 * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
|
||||
${clipped.map((piece) => svg`<path d="${piece.clip.join(' ')}"></path>`)}
|
||||
</clipPath>` : nothing}
|
||||
<mask id="hp-led-mask-${id}" maskUnits="userSpaceOnUse"
|
||||
x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}"
|
||||
color-interpolation="sRGB" style="mask-type:luminance">
|
||||
<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" fill="black"></rect>
|
||||
<g style="isolation:isolate">
|
||||
${paint.map((piece) => svg`<g style="mix-blend-mode:lighten"
|
||||
clip-path=${piece.clip ? `url(#hp-led-clip-${id}-${piece.clipId})` : nothing}>
|
||||
${bands.map((band) => svg`<path d="${piece.d}" fill="none" stroke="${grey(band.value)}"
|
||||
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
|
||||
</g>`)}
|
||||
<g clip-path=${visibilityClip ? `url(#hp-led-visible-${id})` : nothing}>
|
||||
${bands.map((band) => svg`<path d="${geometry.d}" fill="none" stroke="${grey(band.value)}"
|
||||
stroke-width="${2 * band.half * r}" stroke-linecap="round" stroke-linejoin="round"></path>`)}
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
@@ -25,8 +25,8 @@ export const LED_MAX_STRIPS = 50;
|
||||
export const LED_MAX_POINTS = 50;
|
||||
/** Geometric tolerance for "lies on a face": 0.001 cm, never a screen magnet. */
|
||||
export const LED_EPSILON_CM = 0.001;
|
||||
/** Default linear field radius (ТЗ §3): 50 cm, independent of the shared one. */
|
||||
export const LED_DEFAULT_RADIUS_CM = 50;
|
||||
/** Default linear field radius (#784): 30 cm, independent of the shared one. */
|
||||
export const LED_DEFAULT_RADIUS_CM = 30;
|
||||
/** Total stripe thickness in base device diameters (ТЗ §3). */
|
||||
export const LED_THICKNESS_OFF_D = 0.08;
|
||||
export const LED_THICKNESS_ON_D = 0.12;
|
||||
|
||||
+11
-11
@@ -6,13 +6,9 @@
|
||||
* and the hit path; the card keeps the device model, the actions and the
|
||||
* light state, and passes them in. Nothing here writes configuration.
|
||||
*
|
||||
* The linear field is the exact distance field of the strip with the shared
|
||||
* falloff: every piece of the strip paints opaque grey bands of a luminance
|
||||
* mask (round caps and joins, so one piece never doubles itself), pieces meet
|
||||
* through `mix-blend-mode: lighten` — the maximum, i.e. the nearest piece —
|
||||
* and each piece is clipped to the floor its own emitters can see. A hidden
|
||||
* part therefore never lights through another part's visibility (ТЗ §6), a
|
||||
* closed strip has no seam and a corner no double brightness (ТЗ §3).
|
||||
* The linear field paints one continuous path through a union of bounded
|
||||
* visibility regions. Cache boundaries therefore never become visible seams,
|
||||
* while the same wall/door scene still clips the light.
|
||||
*/
|
||||
import { nothing, svg, type TemplateResult } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
@@ -81,7 +77,7 @@ export interface LedStripView {
|
||||
glow: boolean;
|
||||
/** Resolved light colour and per-stop alpha when on; null otherwise. */
|
||||
appearance: { c: string; alpha: number } | null;
|
||||
/** Field radius, plan units: the marker's own radius or 50 cm. */
|
||||
/** Field radius, plan units: the marker's own radius or 30 cm. */
|
||||
radius: number;
|
||||
}
|
||||
|
||||
@@ -261,7 +257,7 @@ export function renderLedStripes(input: LedStripeInput): TemplateResult {
|
||||
<feGaussianBlur stdDeviation="${iso.shadow.sigma}"></feGaussianBlur></filter></defs>` : nothing}
|
||||
<style>
|
||||
.led-strip .led-focus { fill: none; stroke: transparent; }
|
||||
.led-strip:focus-within .led-focus { stroke: var(--primary-color, #03a9f4); }
|
||||
.led-strip:has(.led-hit:focus-visible) .led-focus { stroke: var(--primary-color, #03a9f4); }
|
||||
.led-strip .led-hit { fill: none; stroke: transparent; cursor: pointer; outline: none; }
|
||||
</style>
|
||||
${repeat(ordered, (view) => view.strip.id, (view) => {
|
||||
@@ -541,7 +537,8 @@ export function renderLedLayerFor(
|
||||
export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): TemplateResult {
|
||||
if (host.isConnected === false) return svg`` as unknown as TemplateResult;
|
||||
const frame = ledFrameFor(host, space, spaceGlow);
|
||||
const module = frame.scene && fieldWanted(frame.views) ? ledField(space.id, () => host.isConnected !== false && host.requestUpdate()) : null;
|
||||
const module = frame.scene && (fieldWanted(frame.views) || field?.hasLedField(host))
|
||||
? field || ledField(space.id, () => host.isConnected !== false && host.requestUpdate()) : null;
|
||||
if (!module) return svg`` as unknown as TemplateResult;
|
||||
return module.renderLedField({
|
||||
views: frame.views,
|
||||
@@ -550,6 +547,8 @@ export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlo
|
||||
faces: frame.faces,
|
||||
spaceId: space.id,
|
||||
owner: host,
|
||||
requestUpdate: () => host.requestUpdate(),
|
||||
isConnected: () => host.isConnected !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -611,9 +610,10 @@ export function renderStaticLed(input: StaticLedInput): TemplateResult {
|
||||
occluders: [], floor: [], fingerprint: '',
|
||||
masonryGeometry: input.bodies.masonryGeometry, opaqueBodies: input.bodies.opaqueBodies,
|
||||
}, (LED_EPSILON_CM / input.cellCm) * input.gridPitch);
|
||||
const module = input.scene && input.live && fieldWanted(views)
|
||||
const module = input.scene && input.live && (fieldWanted(views) || field?.hasLedField(input.owner))
|
||||
? ledField(input.space.id, input.ready) : null;
|
||||
return svg`${module ? module.renderLedField({
|
||||
views, scene: input.scene as LightBarrierScene, polygons, faces, spaceId: input.space.id, owner: input.owner,
|
||||
requestUpdate: input.ready, isConnected: () => true,
|
||||
}) : nothing}${renderLedStripes({ views, d: frame.d, faces, perUnit: input.perUnit, handlers: null })}` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ function extrasCss(options: FormKitCssOptions): string {
|
||||
${form} [aria-invalid="true"] { border-color: var(${tokens.danger}) !important; }
|
||||
.${p}-unit { display: inline-flex; align-items: center; height: 44px; border: 1px solid var(${tokens.line}); border-radius: 7px; background: var(${tokens.surface}); }
|
||||
.${p}-unit > input { box-sizing: border-box; width: 5.5em; min-width: 5.5em; height: 100%; padding: 8px 9px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||
.${p}-unit > input:focus { outline: 0; }
|
||||
${form} .${p}-unit > input:focus { outline: 0; }
|
||||
.${p}-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(${tokens.muted}); font-size: .875rem; }
|
||||
.${p}-unit:focus-within { outline: 2px solid var(${tokens.accent}); outline-offset: 2px; }
|
||||
.${p}-unit.${p}-unit-wide, .${p}-unit.${p}-unit-wide > input { width: 100%; min-width: 0; }
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@
|
||||
hp-dialog .hpf-form [aria-invalid="true"] { border-color: var(--hpf-danger) !important; }
|
||||
.hpf-unit { display: inline-flex; align-items: center; height: 44px; border: 1px solid var(--hpf-line); border-radius: 7px; background: var(--hpf-surface); }
|
||||
.hpf-unit > input { box-sizing: border-box; width: 5.5em; min-width: 5.5em; height: 100%; padding: 8px 9px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||
.hpf-unit > input:focus { outline: 0; }
|
||||
hp-dialog .hpf-form .hpf-unit > input:focus { outline: 0; }
|
||||
.hpf-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(--hpf-muted); font-size: .875rem; }
|
||||
.hpf-unit:focus-within { outline: 2px solid var(--hpf-accent); outline-offset: 2px; }
|
||||
.hpf-unit.hpf-unit-wide, .hpf-unit.hpf-unit-wide > input { width: 100%; min-width: 0; }
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@
|
||||
hp-dialog .hpf-form [aria-invalid="true"] { border-color: var(--hpf-danger) !important; }
|
||||
.hpf-unit { display: inline-flex; align-items: center; height: 44px; border: 1px solid var(--hpf-line); border-radius: 7px; background: var(--hpf-surface); }
|
||||
.hpf-unit > input { box-sizing: border-box; width: 5.5em; min-width: 5.5em; height: 100%; padding: 8px 9px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||
.hpf-unit > input:focus { outline: 0; }
|
||||
hp-dialog .hpf-form .hpf-unit > input:focus { outline: 0; }
|
||||
.hpf-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(--hpf-muted); font-size: .875rem; }
|
||||
.hpf-unit:focus-within { outline: 2px solid var(--hpf-accent); outline-offset: 2px; }
|
||||
.hpf-unit.hpf-unit-wide, .hpf-unit.hpf-unit-wide > input { width: 100%; min-width: 0; }
|
||||
|
||||
@@ -146,6 +146,14 @@ test('#594 имена параметризованы, а не зашиты', ()
|
||||
'с включённым флагом ряд-переключатель обязан появляться');
|
||||
});
|
||||
|
||||
test('#784 unit input leaves one focus ring on its wrapper', () => {
|
||||
const css = formKitCss(CARD_DIALOG_FORM_KIT, { withSwitch: false });
|
||||
assert.match(css, /hp-dialog \.hpf-form \.hpf-unit > input:focus \{ outline: 0; \}/,
|
||||
'the inner input must override the more specific generic focus-visible rule');
|
||||
assert.match(css, /\.hpf-unit:focus-within \{ outline: 2px solid var\(--hpf-accent\);/,
|
||||
'the wrapper keeps the single visible focus ring');
|
||||
});
|
||||
|
||||
test('#594 сегментированный переключатель остаётся радиогруппой', () => {
|
||||
const kit = readFileSync(new URL('../src/editors/form-kit.ts', import.meta.url), 'utf8');
|
||||
// Только тело `segmented`: дальше в файле есть другие радиогруппы (radioRow,
|
||||
|
||||
@@ -53,7 +53,8 @@ test('ТЗ §3: the linear falloff is the shared GLOW_FALLOFF', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('AC10/AC17: the compact field bands stay within the r/2 visual tolerance', () => {
|
||||
test('#784: the continuous field has enough bands to avoid visible gradient steps', () => {
|
||||
assert.ok(LED_FIELD_BANDS >= 32, `${LED_FIELD_BANDS} bands are visibly discrete on wide fields`);
|
||||
const fraction = 0.5;
|
||||
const band = Math.floor((1 - fraction) * LED_FIELD_BANDS);
|
||||
const midpoint = 1 - (band + 0.5) / LED_FIELD_BANDS;
|
||||
@@ -65,13 +66,13 @@ test('AC10/AC17: the compact field bands stay within the r/2 visual tolerance',
|
||||
const device = (extra = {}) => ({ id: 'm1', name: 'Kitchen LED', primary: 'light.led', space: 's', ...extra });
|
||||
const strip = { id: 'a', points: [[0, 0], [1, 0]], marker: 'm1' };
|
||||
|
||||
test('AC7: states — off white, on, unavailable without a field; radius 50 cm or the own one', () => {
|
||||
const base = { strip, defaultRadius: 10, cellCm: 5, gridPitch: 1, glow: true };
|
||||
test('#784/AC7: states — off white, on, unavailable without a field; radius 30 cm or the own one', () => {
|
||||
const base = { strip, defaultRadius: 6, cellCm: 5, gridPitch: 1, glow: true };
|
||||
const on = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: 'on' } } },
|
||||
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: { c: '#ff0000', alpha: 0.5 } } });
|
||||
assert.equal(on.state, 'on');
|
||||
assert.deepEqual(on.appearance, { c: '#ff0000', alpha: 0.5 });
|
||||
assert.equal(on.radius, 10, 'the shared radius of ordinary sources does not apply: 50 cm default');
|
||||
assert.equal(on.radius, 6, 'the shared radius of ordinary sources does not apply: 30 cm default');
|
||||
const off = ledStripView({ ...base, device: device(), hass: { states: { 'light.led': { state: 'off' } } },
|
||||
candidate: { key: 's|m1', sourceEid: 'light.led', pos: { x: 0, y: 0 }, radius: 3, appearance: null } });
|
||||
assert.equal(off.state, 'off');
|
||||
@@ -100,6 +101,8 @@ test('ТЗ §6: every piece is clipped to what its own emitters see; a buried st
|
||||
const geometry = buildFieldGeometry({ points: [[1, 1], [9, 1]], radius: 2, scene, polygons, faces, spaceId: 's' });
|
||||
assert.ok(geometry, 'a free strip has a field');
|
||||
assert.equal(geometry.pieces.length, 4, 'an 8-unit segment with r = 2 makes four pieces');
|
||||
assert.equal((geometry.d.match(/M/g) || []).length, 1,
|
||||
'visibility/cache pieces do not split the painted path');
|
||||
for (const piece of geometry.pieces) assert.equal(piece.clip, null, 'nothing within r: the bands are the bound');
|
||||
// Passing 0.5 below the body: the pieces near it are clipped to their own fans.
|
||||
const near = buildFieldGeometry({ points: [[0.5, 3.5], [9.5, 3.5]], radius: 1, scene, polygons, faces, spaceId: 's' });
|
||||
@@ -118,6 +121,20 @@ test('ТЗ §6: every piece is clipped to what its own emitters see; a buried st
|
||||
assert.equal(buried, null, 'entirely inside the body: no field');
|
||||
});
|
||||
|
||||
test('#784: a corner and a closed strip remain one painted path', () => {
|
||||
const faces = faceContext(scene, 1e-6);
|
||||
const corner = buildFieldGeometry({ points: [[1, 1], [9, 1], [9, 9]], radius: 2,
|
||||
scene, polygons, faces, spaceId: 's' });
|
||||
assert.ok(corner && corner.pieces.length > 1);
|
||||
assert.equal((corner.d.match(/M/g) || []).length, 1);
|
||||
assert.match(corner.d, /L9 1 L9 9$/);
|
||||
const closed = buildFieldGeometry({ points: [[1, 1], [3, 1], [3, 3], [1, 3], [1, 1]], radius: 1,
|
||||
scene, polygons, faces, spaceId: 's' });
|
||||
assert.ok(closed);
|
||||
assert.equal((closed.d.match(/M/g) || []).length, 1);
|
||||
assert.match(closed.d, / Z$/);
|
||||
});
|
||||
|
||||
test('AC17: the field cache is bounded, per space, and counts geometry rebuilds', () => {
|
||||
const cache = new LedFieldCache(3);
|
||||
cache.forSpace('a');
|
||||
@@ -130,7 +147,7 @@ test('AC17: the field cache is bounded, per space, and counts geometry rebuilds'
|
||||
assert.equal(cache.size, 0, 'another space frees the previous one');
|
||||
});
|
||||
|
||||
test('AC9: the frame gives every strip the 50 cm default, not the shared radius; unbound strips have no view', () => {
|
||||
test('#784/AC9: the frame gives every strip the 30 cm default, not the shared radius; unbound strips have no view', () => {
|
||||
const lamp = { id: 'm1', name: 'Lamp', primary: 'light.led', entities: ['light.led'], space: 's', marker: { id: 'm1', binding: 'device:m1' } };
|
||||
const frame = ledFrame({
|
||||
space: { id: 's', vb: [0, 0, 1000, 1000], rooms: [], led_strips: [
|
||||
@@ -143,7 +160,7 @@ test('AC9: the frame gives every strip the 50 cm default, not the shared radius;
|
||||
scene: null, polygons: [], glowFor: () => true, inRoom: () => false, showHidden: false,
|
||||
});
|
||||
assert.equal(frame.views.length, 1, 'the unbound strip is not a View strip');
|
||||
assert.ok(Math.abs(frame.views[0].radius - (50 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`);
|
||||
assert.ok(Math.abs(frame.views[0].radius - (30 / 5) * (1000 / 240)) < 1e-9, `radius ${frame.views[0].radius}`);
|
||||
});
|
||||
|
||||
test('AC2/r1 M2: an explicit valid room_id wins over the anchor room; a stale one falls back', () => {
|
||||
|
||||
Reference in New Issue
Block a user