mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -148,6 +148,8 @@ jobs:
|
||||
run: |
|
||||
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
|
||||
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
|
||||
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
|
||||
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
|
||||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
|
||||
@@ -161,6 +163,7 @@ jobs:
|
||||
working-directory: candidate
|
||||
run: |
|
||||
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
|
||||
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
|
||||
|
||||
|
||||
@@ -272,6 +272,22 @@ Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
|
||||
reproduces against the pristine committed bundle, so treat it as
|
||||
pre-existing/pixel-precision, not a regression you introduced.
|
||||
|
||||
## Labs flags
|
||||
|
||||
`src/labs.ts` is the single registry and resolver for hidden presentation
|
||||
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
|
||||
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
|
||||
YAML/config switch for a Labs-only experiment. A new entry needs a unique
|
||||
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
|
||||
unit/browser coverage. Invalid or duplicate registry entries fail closed.
|
||||
|
||||
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
|
||||
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
|
||||
experiment or graduate it through its own reviewed issue; never extend expiry as
|
||||
an incidental change. Labs may alter presentation only and must not gate data,
|
||||
migrations, stores, HA actions or network calls. Current renderer details are in
|
||||
`docs/ISOMETRIC.md`.
|
||||
|
||||
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
|
||||
page directly in a browser renders the floor plan but **device icons only appear
|
||||
after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { launch } from './serve.mjs';
|
||||
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
@@ -13,6 +13,11 @@ const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
||||
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
|
||||
const output = valueArg('output') ? resolve(valueArg('output')) : null;
|
||||
const targetRoot = resolve(valueArg('target-root') ?? '.');
|
||||
const profile = valueArg('profile') ?? 'large-house-v1';
|
||||
if (!['large-house-v1', 'large-house-isometric-v1'].includes(profile))
|
||||
throw new Error(`unknown large-house profile: ${profile}`);
|
||||
const isometric = profile === 'large-house-isometric-v1';
|
||||
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const viewport = { width: 1440, height: 1000 };
|
||||
|
||||
@@ -43,7 +48,7 @@ const rows = [];
|
||||
try {
|
||||
for (let iteration = 0; iteration < warmups + samples; iteration++) {
|
||||
const measuredSample = iteration - warmups;
|
||||
const row = await page.evaluate(async ({ fixture, sample, cardContract }) => {
|
||||
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const until = async (predicate, timeout = 10000) => {
|
||||
const started = performance.now();
|
||||
@@ -98,10 +103,18 @@ try {
|
||||
wallUnion: card._wallUnionCache ? 1 : 0,
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
isoGeometry: card._isoGeometryCache?.size ?? 0,
|
||||
});
|
||||
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
if (isometric) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
|
||||
fixture.config.spaces.map((space) => [space.id, 'iso']),
|
||||
)));
|
||||
history.replaceState(null, '', '?hp-labs=iso');
|
||||
} else history.replaceState(null, '', location.pathname);
|
||||
const host = document.getElementById('host');
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({
|
||||
@@ -144,6 +157,10 @@ try {
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(fixture.states);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
if (requiresIsometric && (typeof card._setProjection !== 'function'
|
||||
|| !(card._isoGeometryCache instanceof Map))) {
|
||||
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
|
||||
}
|
||||
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
@@ -152,6 +169,18 @@ try {
|
||||
await frame();
|
||||
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
|
||||
const loadLongTaskResult = await loadLongTasks.stop();
|
||||
const viewToggle = isometric ? await duration(async () => {
|
||||
if (typeof card._setProjection === 'function') {
|
||||
card._setProjection('flat');
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
} else {
|
||||
// Comparison SHAs before #89 intentionally ignore the Labs operation.
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
}) : null;
|
||||
const spaceSwitch = await duration(async () => {
|
||||
card._pickSpace('perf-floor-2');
|
||||
await card.updateComplete;
|
||||
@@ -249,6 +278,7 @@ try {
|
||||
sample,
|
||||
modelReadyMs,
|
||||
firstStableRenderMs,
|
||||
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
|
||||
spaceSwitchMs: spaceSwitch.ms,
|
||||
stateUpdateMs: stateUpdate.ms,
|
||||
resizePreviewMs: resizePreview.ms,
|
||||
@@ -257,6 +287,7 @@ try {
|
||||
switchCycleMs: switchCycle.ms,
|
||||
longTasks: {
|
||||
load: loadLongTaskResult,
|
||||
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
|
||||
spaceSwitch: spaceSwitch.longTasks,
|
||||
stateUpdate: stateUpdate.longTasks,
|
||||
resizePreview: resizePreview.longTasks,
|
||||
@@ -273,7 +304,10 @@ try {
|
||||
card.remove();
|
||||
await frame();
|
||||
return result;
|
||||
}, { fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT });
|
||||
}, {
|
||||
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
|
||||
isometric, requiresIsometric,
|
||||
});
|
||||
if (measuredSample >= 0) rows.push(row);
|
||||
}
|
||||
} finally {
|
||||
@@ -284,9 +318,10 @@ const metricNames = [
|
||||
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
|
||||
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
|
||||
];
|
||||
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
|
||||
const report = {
|
||||
schema: 2,
|
||||
profile: 'large-house-v1',
|
||||
profile,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildFingerprint,
|
||||
runtime: {
|
||||
@@ -304,7 +339,7 @@ const report = {
|
||||
summary: summarizeTimings(rows, metricNames),
|
||||
longTasks: summarizeLongTasks(rows),
|
||||
rows,
|
||||
note: 'Compare with a base-SHA report captured by the same runner and evaluate demo/performance/budgets.json.',
|
||||
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
|
||||
};
|
||||
|
||||
const text = `${JSON.stringify(report, null, 2)}\n`;
|
||||
|
||||
+18
-1
@@ -62,13 +62,14 @@ export function prepareGoldenFixture(scenario) {
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean') {
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
|
||||
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
|
||||
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
|
||||
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
|
||||
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
|
||||
};
|
||||
}
|
||||
@@ -204,10 +205,21 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
window.__goldenCard?.remove?.();
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
history.replaceState(null, '', scenario.labs?.length
|
||||
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
|
||||
if (scenario.labs?.length) {
|
||||
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
|
||||
}
|
||||
if (scenario.projection && scenario.space) {
|
||||
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
|
||||
[scenario.space]: scenario.projection,
|
||||
}));
|
||||
}
|
||||
const host = document.getElementById('host');
|
||||
const cardConfig = {
|
||||
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
|
||||
language: scenario.language || 'en',
|
||||
...(scenario.kiosk ? { kiosk: true } : {}),
|
||||
};
|
||||
const hassFor = () => ({
|
||||
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
|
||||
@@ -268,6 +280,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await card.updateComplete;
|
||||
await settleMode(card);
|
||||
}
|
||||
if (scenario.projection === 'iso' && typeof card._setProjection === 'function') {
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
card._applyView(scenario.zoom, 500, 500);
|
||||
card.requestUpdate();
|
||||
|
||||
+17
-1
@@ -1,13 +1,29 @@
|
||||
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 = 16;
|
||||
export const GOLDEN_MATRIX_VERSION = 17;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', showBorders: false, fillMode: 'custom',
|
||||
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', kiosk: true,
|
||||
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
|
||||
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', warmRemount: true,
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
|
||||
40 columns and 500 decor objects on three floors.
|
||||
|
||||
Issue #89 adds `large-house-isometric-v1` through the same runner. It enables
|
||||
`hp-labs=iso`, selects iso per fixture space, measures the View toggle and
|
||||
records the capped `isoGeometry` cache. A comparison SHA predating #89 remains
|
||||
flat while reporting the same profile. The dedicated
|
||||
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
|
||||
The runner records seven measured samples after one discarded warm-up. With
|
||||
this intentionally small CI sample, the nearest-rank `p95` is the observed
|
||||
maximum; reports keep the conventional field name but should be read as a
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "large-house-isometric-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 },
|
||||
"firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 },
|
||||
"viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 },
|
||||
"stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 },
|
||||
"settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 },
|
||||
"switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.2,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.2,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.2,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.2,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 160,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"isoGeometry": 8
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"isoGeometry": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -37,7 +37,9 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
]),
|
||||
optionalFields: Object.freeze([]),
|
||||
// A comparison SHA before #89 is intentionally flat; the isometric runner
|
||||
// checks these two members only when the target source tree supports Stage 1.
|
||||
optionalFields: Object.freeze(['_isoGeometryCache', '_setProjection']),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_cleanFloorCache: 'map',
|
||||
@@ -46,6 +48,8 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
_gridPitch: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_model: 'array',
|
||||
_isoGeometryCache: 'map',
|
||||
_setProjection: 'function',
|
||||
_tool: 'string',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// #89 Stage 1: Labs lifecycle, toggle, projection parity and flat editor boundary.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 850 });
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.renderRoot;
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const center = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
|
||||
};
|
||||
const enable = async () => {
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=iso');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
};
|
||||
const result = {};
|
||||
const configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
...(configSpace.settings || {}), show_borders: true, show_names: true,
|
||||
};
|
||||
configSpace.partitions = [{
|
||||
id: 'iso-smoke-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
|
||||
}];
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await enable();
|
||||
const toggle = root().querySelector('[data-hp="projection-toggle"]');
|
||||
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
|
||||
&& JSON.stringify(window.__hpLabs) === '["iso"]';
|
||||
result.toggleShown = !!toggle;
|
||||
result.toggleMinHitTarget = toggle && toggle.getBoundingClientRect().width >= 44
|
||||
&& toggle.getBoundingClientRect().height >= 44;
|
||||
result.flatDefault = toggle?.getAttribute('aria-pressed') === 'false'
|
||||
&& !root().querySelector('[data-hp="iso-walls"]');
|
||||
|
||||
toggle?.click();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const isoToggle = root().querySelector('[data-hp="projection-toggle"]');
|
||||
const device = root().querySelector('[data-hp="device"]');
|
||||
const roomLabel = root().querySelector('[data-hp="room-label"]');
|
||||
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
|
||||
result.preferenceStored = JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
|
||||
result.anchorsFinite = [device, roomLabel].every((node) => node
|
||||
&& center(node).every((value) => Number.isFinite(value)));
|
||||
const deviceBefore = device ? center(device) : null;
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
||||
await card.updateComplete;
|
||||
const deviceAfter = root().querySelector('[data-hp="device"]');
|
||||
result.haUpdateKeepsGeometry = root().querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint
|
||||
&& card._isoGeometryCache.size === 1;
|
||||
result.haUpdateKeepsAnchor = !!deviceBefore && !!deviceAfter
|
||||
&& Math.hypot(...center(deviceAfter).map((value, index) => value - deviceBefore[index])) <= 1;
|
||||
|
||||
card._setMode('plan');
|
||||
await card.updateComplete;
|
||||
while (card._modeTransitionBusy) await frame();
|
||||
result.editorIsFlat = card._mode === 'plan'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]')
|
||||
&& !root().querySelector('[data-hp="iso-walls"]');
|
||||
card._setMode('view');
|
||||
await card.updateComplete;
|
||||
while (card._modeTransitionBusy) await frame();
|
||||
result.viewRestoresIso = root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true'
|
||||
&& !!root().querySelector('[data-hp="iso-walls"]');
|
||||
|
||||
const source = card._isoSource;
|
||||
let attempts = 0;
|
||||
let shouldFail = true;
|
||||
const fallbackKey = 'f1|injected-render-failure';
|
||||
card._isoGeometryCache.clear();
|
||||
card._isoSource = () => ({
|
||||
key: fallbackKey,
|
||||
build: () => {
|
||||
attempts++;
|
||||
if (shouldFail) throw new Error('injected isometric failure');
|
||||
return [];
|
||||
},
|
||||
});
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states } };
|
||||
await card.updateComplete;
|
||||
result.fallbackLatched = attempts === 1
|
||||
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'false'
|
||||
&& !root().querySelector('[data-hp="iso-walls"]')
|
||||
&& JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
|
||||
shouldFail = false;
|
||||
card._setProjection('iso');
|
||||
await card.updateComplete;
|
||||
result.explicitRetryRestoresIso = attempts === 2
|
||||
&& !!root().querySelector('[data-hp="iso-walls"]')
|
||||
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
|
||||
card._isoSource = source;
|
||||
card._isoGeometryCache.delete(fallbackKey);
|
||||
card._isoFallback.delete(fallbackKey);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
history.replaceState(null, '', '#space=f1&hp-labs=-iso');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
|
||||
&& !root().querySelector('[data-hp="projection-toggle"]')
|
||||
&& !root().querySelector('[data-hp="iso-walls"]');
|
||||
return result;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,283 @@
|
||||
// #89 Stage 1: Q6 live-floor preservation plus touch, kiosk and remount paths.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch(
|
||||
{ width: 430, height: 820 }, 1, [], { hasTouch: true, isMobile: true },
|
||||
);
|
||||
const out = await page.evaluate(async () => {
|
||||
const original = window.__card;
|
||||
const root = (card) => card.renderRoot;
|
||||
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await original.updateComplete;
|
||||
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
|
||||
configSpace.settings = {
|
||||
...(configSpace.settings || {}), fill_mode: 'custom',
|
||||
custom_fill: { c: '#31465f', a: 0.42 }, glow_enabled: true,
|
||||
show_borders: true, show_names: true, north_deg: 0, sun_rays: true,
|
||||
};
|
||||
configSpace.rooms[0].settings = {
|
||||
...(configSpace.rooms[0].settings || {}), fill_mode: 'none', glow: true,
|
||||
};
|
||||
configSpace.openings = [
|
||||
{ id: 'iso-window', type: 'window', x: 0.28, y: 0.14, angle: 0, length: 0.12,
|
||||
contact: 'binary_sensor.window' },
|
||||
{ id: 'iso-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12,
|
||||
contact: 'binary_sensor.window', lock: 'lock.front_door' },
|
||||
];
|
||||
configSpace.partitions = [{
|
||||
id: 'iso-live-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
|
||||
}];
|
||||
configSpace.decor = [
|
||||
{ id: 'iso-line', kind: 'line', x1: 0.12, y1: 0.7, x2: 0.45, y2: 0.7,
|
||||
color: '#667788', opacity: 0.7, width_cm: 1 },
|
||||
{ id: 'iso-sofa', kind: 'furniture', symbol: 'sofa', x: 0.62, y: 0.55,
|
||||
w: 0.2, h: 0.12, color: '#667788', opacity: 0.7, width_cm: 1 },
|
||||
];
|
||||
original._serverCfg.markers = original._serverCfg.markers || [];
|
||||
original._serverCfg.markers.push({
|
||||
id: 'iso-vacuum', binding: 'device:d_mower', space: 'f1', area: 'living_room',
|
||||
vacuum: {
|
||||
source: 'camera.static_robot_map', trail_mode: 'always',
|
||||
calibration: { m1: [0.5, 0, 0, 0, 0.5, 0] },
|
||||
},
|
||||
});
|
||||
original._layout['iso-vacuum'] = { s: 'f1', x: 0.18, y: 0.78 };
|
||||
original._hoverRoom = { space: 'f1', room: original._spaceModel('f1').rooms[0] };
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
states: {
|
||||
...original.hass.states,
|
||||
'light.floor_lamp': {
|
||||
...original.hass.states['light.floor_lamp'], state: 'on',
|
||||
},
|
||||
'vacuum.mower': {
|
||||
...original.hass.states['vacuum.mower'], state: 'cleaning',
|
||||
},
|
||||
'camera.static_robot_map': {
|
||||
entity_id: 'camera.static_robot_map', state: 'idle',
|
||||
attributes: {
|
||||
map_name: 'm1', vacuum_position: { x: 650, y: 700, a: 0 },
|
||||
path: [[200, 200], [420, 440], [650, 700]],
|
||||
},
|
||||
},
|
||||
'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 24 },
|
||||
},
|
||||
},
|
||||
};
|
||||
original._cfgEpoch++;
|
||||
original._regSignature = '';
|
||||
original.requestUpdate();
|
||||
await original.updateComplete;
|
||||
await frame();
|
||||
|
||||
const count = (selector) => root(original).querySelectorAll(selector).length;
|
||||
const layers = () => ({
|
||||
rooms: count('[data-hp="room"]'),
|
||||
roomFills: count('[data-hp="room"].filled'),
|
||||
decor: count('.decorlayer [data-hp="decor"]'),
|
||||
furniture: count('.decorlayer .dfurn'),
|
||||
backdrop: count('.hp-backdrop'),
|
||||
glowBase: count('.glow-base-layer .glow-base'),
|
||||
glowSources: count('.glowlayer [data-glow-source]'),
|
||||
sun: count('.sunlayer'),
|
||||
openings: count('[data-hp="opening"]'),
|
||||
devices: count('[data-hp="device"]'),
|
||||
hover: count('.room-hover-fill'),
|
||||
vacuumPucks: count('.vacpuck'),
|
||||
vacuumTrails: count('.vactrail .case'),
|
||||
});
|
||||
const flat = layers();
|
||||
const flatSpillParts = [...root(original).querySelectorAll('.glow-pool')]
|
||||
.map((node) => node.getAttribute('data-lit-parts'));
|
||||
root(original).querySelector('[data-hp="projection-toggle"]')?.click();
|
||||
await original.updateComplete;
|
||||
await frame();
|
||||
|
||||
const before = layers();
|
||||
const beforeSpillParts = [...root(original).querySelectorAll('.glow-pool')]
|
||||
.map((node) => node.getAttribute('data-lit-parts'));
|
||||
const wallFingerprint = root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
|
||||
const cachedGeometry = original._isoGeometryCache.get(wallFingerprint)?.geometry;
|
||||
const light = original.hass.states['light.ceiling'];
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
states: {
|
||||
...original.hass.states,
|
||||
'light.ceiling': {
|
||||
...light, attributes: { ...light.attributes, rgb_color: [255, 64, 32] },
|
||||
},
|
||||
},
|
||||
};
|
||||
await original.updateComplete;
|
||||
const after = layers();
|
||||
const afterSpillParts = [...root(original).querySelectorAll('.glow-pool')]
|
||||
.map((node) => node.getAttribute('data-lit-parts'));
|
||||
const ordered = [
|
||||
root(original).querySelector('.hp-backdrop'),
|
||||
root(original).querySelector('.decorlayer'),
|
||||
root(original).querySelector('[data-hp="room"]'),
|
||||
root(original).querySelector('.glow-base-layer'),
|
||||
root(original).querySelector('.glowlayer'),
|
||||
root(original).querySelector('.sunlayer'),
|
||||
root(original).querySelector('.iso-walls-svg'),
|
||||
root(original).querySelector('.vacpuck'),
|
||||
];
|
||||
const result = {
|
||||
isoOnTouch: !!root(original).querySelector('[data-hp="iso-walls"]'),
|
||||
flatIsoLayerParity: JSON.stringify(flat) === JSON.stringify(before)
|
||||
&& JSON.stringify(flatSpillParts) === JSON.stringify(beforeSpillParts),
|
||||
liveLayersPresent: before.rooms > 0 && before.roomFills > 0 && before.decor >= 2
|
||||
&& before.furniture > 0 && before.backdrop > 0 && before.glowBase > 0
|
||||
&& before.glowSources >= 2 && before.sun > 0 && before.openings >= 2
|
||||
&& before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0
|
||||
&& before.vacuumTrails >= 2,
|
||||
liveLayersStable: JSON.stringify(after) === JSON.stringify(before),
|
||||
spillBarrierStable: JSON.stringify(afterSpillParts) === JSON.stringify(beforeSpillParts)
|
||||
&& beforeSpillParts.length >= 2 && beforeSpillParts.every((parts) => Number(parts) > 0),
|
||||
floorToOverlayOrderPreserved: ordered.every(Boolean)
|
||||
&& ordered.slice(1).every((node, index) => Boolean(
|
||||
ordered[index].compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
)),
|
||||
haColorUpdatePainted: [...root(original).querySelectorAll('radialGradient stop')]
|
||||
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
|
||||
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
|
||||
haUpdateReusesGeometry: original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry,
|
||||
noStage1VerticalOpenings: !root(original).querySelector('.iso-window,.window-light,.vertical-door'),
|
||||
};
|
||||
|
||||
const calls = [];
|
||||
original.hass = {
|
||||
...original.hass,
|
||||
callService: async (domain, service, data) => calls.push({ domain, service, data }),
|
||||
};
|
||||
await original.updateComplete;
|
||||
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
|
||||
await frame();
|
||||
original._setProjection('flat');
|
||||
await original.updateComplete;
|
||||
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
|
||||
await frame();
|
||||
result.flatIsoActionParity = calls.length === 2
|
||||
&& JSON.stringify(calls[0]) === JSON.stringify(calls[1]);
|
||||
original._setProjection('iso');
|
||||
await original.updateComplete;
|
||||
|
||||
const stage = root(original).querySelector('.stage');
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const zoomBefore = original._zoom;
|
||||
const pointer = (type, id, x, y, buttons) => new PointerEvent(type, {
|
||||
bubbles: true, composed: true, pointerId: id, pointerType: 'touch',
|
||||
clientX: x, clientY: y, button: type === 'pointerdown' ? 0 : -1, buttons,
|
||||
});
|
||||
const cy = stageRect.top + stageRect.height / 2;
|
||||
stage.dispatchEvent(pointer('pointerdown', 8902, stageRect.left + 150, cy, 1));
|
||||
stage.dispatchEvent(pointer('pointerdown', 8903, stageRect.left + 270, cy, 1));
|
||||
stage.dispatchEvent(pointer('pointermove', 8902, stageRect.left + 115, cy, 1));
|
||||
stage.dispatchEvent(pointer('pointermove', 8903, stageRect.left + 305, cy, 1));
|
||||
stage.dispatchEvent(pointer('pointerup', 8902, stageRect.left + 115, cy, 0));
|
||||
stage.dispatchEvent(pointer('pointerup', 8903, stageRect.left + 305, cy, 0));
|
||||
await original.updateComplete;
|
||||
result.touchPinchKeepsIso = original._zoom > zoomBefore
|
||||
&& !!root(original).querySelector('[data-hp="iso-walls"]');
|
||||
|
||||
const opening = root(original).querySelector('[data-hp="opening"] .op-hit');
|
||||
opening?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
|
||||
result.touchOpeningIsSafe = !original._openingDialog && !original._openingInfo
|
||||
&& original._mode === 'view' && calls.length === 2;
|
||||
|
||||
const device = root(original).querySelector('[data-hp="device"]');
|
||||
if (device) {
|
||||
const rect = device.getBoundingClientRect();
|
||||
device.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
bubbles: true, composed: true, pointerId: 8901, pointerType: 'touch',
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
button: 0, buttons: 1,
|
||||
}));
|
||||
await wait(700);
|
||||
result.touchLongPressHitsDevice = !!original._infoCard;
|
||||
device.dispatchEvent(new PointerEvent('pointerup', {
|
||||
bubbles: true, composed: true, pointerId: 8901, pointerType: 'touch',
|
||||
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
||||
button: 0, buttons: 0,
|
||||
}));
|
||||
original._closeInfoCard();
|
||||
} else result.touchLongPressHitsDevice = false;
|
||||
|
||||
original._pickSpace('garden');
|
||||
await wait(230);
|
||||
original._setProjection('iso');
|
||||
await original.updateComplete;
|
||||
const gardenIso = original._space === 'garden'
|
||||
&& root(original).querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
|
||||
original._pickSpace('f1');
|
||||
await wait(230);
|
||||
result.touchSpaceSwitchKeepsPerSpaceIso = gardenIso && original._space === 'f1'
|
||||
&& !!root(original).querySelector('[data-hp="iso-walls"]');
|
||||
|
||||
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
|
||||
await original.updateComplete;
|
||||
await frame();
|
||||
const recoveryStarted = performance.now();
|
||||
while (root(original).querySelector('.recoveryoverlay')
|
||||
&& performance.now() - recoveryStarted < 2000) await wait(20);
|
||||
result.backgroundForegroundKeepsIso = !!root(original).querySelector('[data-hp="iso-walls"]')
|
||||
&& !root(original).querySelector('.recoveryoverlay');
|
||||
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
|
||||
else delete document.visibilityState;
|
||||
|
||||
const host = document.getElementById('host');
|
||||
const mount = async (kiosk = false) => {
|
||||
const card = document.createElement('houseplan-card');
|
||||
card.setConfig({ type: 'custom:houseplan-card', kiosk });
|
||||
host.replaceChildren(card);
|
||||
card.hass = window.__mkHass();
|
||||
const started = performance.now();
|
||||
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
return card;
|
||||
};
|
||||
original.remove();
|
||||
const warm = await mount(false);
|
||||
result.warmRemountIso = !warm._booting
|
||||
&& !!root(warm).querySelector('[data-hp="iso-walls"]')
|
||||
&& root(warm).querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
|
||||
warm.remove();
|
||||
const kiosk = await mount(true);
|
||||
result.kioskReadsPreference = !!root(kiosk).querySelector('[data-hp="iso-walls"]');
|
||||
result.kioskHasNoToggle = !root(kiosk).querySelector('[data-hp="projection-toggle"]');
|
||||
return result;
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 820, height: 430 });
|
||||
out.orientationResizeKeepsIso = await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const anchor = card.renderRoot.querySelector('[data-hp="device"]')?.getBoundingClientRect();
|
||||
return !!card.renderRoot.querySelector('[data-hp="iso-walls"]')
|
||||
&& !!anchor && [anchor.left, anchor.top, anchor.width, anchor.height].every(Number.isFinite);
|
||||
});
|
||||
out.kioskEmergencyOffIsFlat = await page.evaluate(async () => {
|
||||
const card = document.querySelector('houseplan-card');
|
||||
history.replaceState(null, '', '?hp-labs=-iso#space=f1');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
return !card.renderRoot.querySelector('[data-hp="iso-walls"]')
|
||||
&& !card.renderRoot.querySelector('[data-hp="projection-toggle"]');
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
+290
-248
File diff suppressed because one or more lines are too long
Vendored
+46
-46
File diff suppressed because one or more lines are too long
@@ -105,6 +105,23 @@ git config core.untrackedCache true
|
||||
|
||||
## Maintenance diagnostics
|
||||
|
||||
### Labs presentation flags
|
||||
|
||||
Labs is an internal, presentation-only runtime in `src/labs.ts`; it must never
|
||||
gate config/schema migrations, persistence writes, HA services or network
|
||||
requests. Enable the current experiment with `?hp-labs=iso` (or
|
||||
`#hp-labs=iso&space=<id>`), remove it with `-iso`, and clear all flags with
|
||||
`hp-labs=off`. Known URL operations persist to
|
||||
`houseplan_card_labs_v1` without rewriting the URL. Active flags are exposed for
|
||||
diagnostics as the frozen sorted `window.__hpLabs` array.
|
||||
|
||||
To add a flag, add one unique lowercase id plus issue, `since`, `expires` and a
|
||||
non-empty summary to `LABS_FLAGS`, then cover URL/storage ordering, runtime
|
||||
subscription and expiry. Versions compare the numeric `major.minor.patch` core:
|
||||
at `expires` (including its first beta) the flag is dead and every activation
|
||||
path fails closed. Remove or graduate a flag before its expiry cycle; do not
|
||||
extend an expiry silently. See `docs/ISOMETRIC.md` for the current Stage 1 use.
|
||||
|
||||
These commands are read-only diagnostics, not release gates:
|
||||
|
||||
```bash
|
||||
@@ -115,6 +132,9 @@ npm run audit:config -- path/to/houseplan-config.json
|
||||
# Reproducible synthetic large-house report (seven measured samples + warm-up).
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
|
||||
# Hidden isometric profile; diagnostic only outside exact-SHA Linux CI.
|
||||
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
|
||||
|
||||
# Golden candidates never overwrite reviewed references.
|
||||
npm run golden:capture
|
||||
npm run golden:verify
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Isometric Stage 1 internals
|
||||
|
||||
Issue [#89](https://github.com/Matysh/houseplan-card/issues/89) implements a
|
||||
hidden, presentation-only volumetric View experiment. The normative contract is
|
||||
`docs/specs/089-isometric-view-stage1.md`; the fixed rendering decisions are in
|
||||
`docs/adr/089-isometric-stage1-renderer.md`.
|
||||
|
||||
## Activation and lifetime
|
||||
|
||||
The feature exists only while the `iso` entry in `src/labs.ts` is live. For the
|
||||
v1.62 cycle it can be enabled with either `?hp-labs=iso` or
|
||||
`#hp-labs=iso&space=<id>`. Query operations are applied first and hash operations
|
||||
second. `-iso` removes this flag and `off` clears every Labs flag. A known URL
|
||||
operation is persisted in `houseplan_card_labs_v1`; the URL itself is not
|
||||
rewritten.
|
||||
|
||||
The selected presentation is stored per space in `houseplan_card_view_v1`. Flat
|
||||
is always the initial default. Kiosk has no toggle but reads the saved preference.
|
||||
Editors and `houseplan-space-card` are always flat.
|
||||
|
||||
The current registry entry is live from numeric core `1.62.0` and expires at
|
||||
`1.65.0`. Thus `1.65.0-beta.1` is already expired. Malformed versions, malformed
|
||||
entries and duplicate ids fail closed. Labs never gate schemas, migrations,
|
||||
stores, service calls or network requests.
|
||||
|
||||
## Coordinate systems
|
||||
|
||||
- Plan points are the existing 1000-unit logical floor coordinates.
|
||||
- Scene points are coordinates in the effective SVG viewBox.
|
||||
- HTML devices, vacuum pucks, room labels/cards and lock markers stay
|
||||
screen-facing. Their anchors use the same `projectPlanPoint()` snapshot as the
|
||||
SVG floor.
|
||||
- `clientToScenePoint()` maps a client point to the current scene; floor hit
|
||||
testing then uses `unprojectFloorPoint()`.
|
||||
|
||||
Stage 1 uses a fixed orthographic affine camera:
|
||||
|
||||
```text
|
||||
rotDeg=0, tiltDeg=20, xyScale=1, zScale=1, origin=[500,500]
|
||||
wallHeight=64 plan units
|
||||
```
|
||||
|
||||
There is no perspective, free rotation or user tilt. Switching projections
|
||||
preserves scalar zoom and converts the view centre through logical floor space.
|
||||
`projectedFrame()` includes both floor corners and wall-top corners so fit/home
|
||||
cannot clip the volume.
|
||||
|
||||
## Geometry and composition
|
||||
|
||||
`src/iso-projection.ts` owns pure projection math. `src/iso-walls.ts` consumes
|
||||
the canonical `wallBodiesGeometry()` MultiPolygon after openings and extra
|
||||
physical bodies have been resolved. It normalizes outer/hole winding, builds one
|
||||
evenodd top path and at most one visible side per ring edge, then uses a stable
|
||||
depth/order tie-break. Complexity is O(E) in canonical ring edges.
|
||||
|
||||
The content fingerprint includes rooms, wall geometry/thickness, open cuts,
|
||||
openings, partitions, drafts, columns, scale/grid inputs, camera, wall height and
|
||||
algorithm version. It deliberately excludes `_cfgEpoch`, HA state, hover and
|
||||
`show_borders`. The per-card LRU is capped at eight scenes. Pan, zoom and HA-only
|
||||
updates reuse it.
|
||||
|
||||
Composition remains SVG-first:
|
||||
|
||||
1. the existing floor SVG and all its current live layers;
|
||||
2. explicit visible wall sides and wall top;
|
||||
3. screen-facing HTML overlays.
|
||||
|
||||
The floor keeps the same nodes and order for paper/backdrop, room fills/hover,
|
||||
Glow/spill, sun, decor/furniture, opening symbols and vacuum path/outline. Stage
|
||||
1 does not add a second light source/layer. Markers and room cards intentionally
|
||||
remain above walls without geometric occlusion.
|
||||
|
||||
## Failure boundary
|
||||
|
||||
Projection/topology failures latch flat fallback for the current
|
||||
`space|fingerprint`. One diagnostic is emitted without config, entity ids or URL
|
||||
data. The saved iso preference is retained; an explicit iso request retries the
|
||||
fingerprint, and changed geometry receives a new fingerprint. Flat rendering is
|
||||
the rollback path and does not depend on the iso cache.
|
||||
|
||||
## Deliberate Stage 1 limits
|
||||
|
||||
- door, window and gate keep their current floor-plane symbol and live state;
|
||||
- no vertical leaf/window panels, sill model or new window light;
|
||||
- no floor-edge extrusion, shadows, photorealistic materials or marker
|
||||
occlusion;
|
||||
- no volumetric editor and no volumetric `houseplan-space-card`;
|
||||
- no YAML/config option or public settings surface.
|
||||
|
||||
Golden references are accepted only from the complete reviewed Linux artifact.
|
||||
The full `large-house-isometric-v1` performance comparison is also canonical on
|
||||
the exact Linux CI SHA.
|
||||
+3
-2
@@ -17,12 +17,13 @@ change must pass through a published beta/RC before stable. Stable release
|
||||
commits are promotion-only (versions, generated bundles and release/changelog
|
||||
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
## Snapshot (2026-08-12)
|
||||
## Snapshot (2026-08-13)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.62.0-beta.8** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
|
||||
| Current local cycle | v1.62.0-beta.8 combines Window / Door / Gate placement under the Opening sub-menu, renders the full 50%-opacity placement preview over physical walls, fixes first-click editor closing and incorporates the agreed beta.7 recursive code-review hardening. Issues #89 and #103 remain specification/backlog-only. |
|
||||
| Current local cycle | v1.62.0-beta.8 combines Window / Door / Gate placement under the Opening sub-menu, renders the full 50%-opacity placement preview over physical walls, fixes first-click editor closing and incorporates the agreed beta.7 recursive code-review hardening. Issue #103 remains specification/backlog-only. |
|
||||
| Hidden Labs Stage | #89 Stage 1 implements a fixed near-top orthographic volumetric View behind the expiring `iso` Labs flag. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a released/public feature. |
|
||||
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/89
|
||||
- Приоритет: **P1**, feature
|
||||
- Статус ТЗ: **на независимом ревью** (ревизия 3, решения владельца 2026-08-13).
|
||||
Ревизия 2 предшествует действующему процессу и новым визуальным референсам;
|
||||
эта редакция обязана получить независимое ревью до `S5-ready`.
|
||||
- Статус ТЗ: **принято к реализации** (ревизия 3, решения владельца и зелёное
|
||||
независимое ревью ТЗ 2026-08-13).
|
||||
Ревизия 2 предшествует действующему процессу и новым визуальным референсам.
|
||||
- Исследование и продуктовое обоснование: [`089-isometric-view.md`](089-isometric-view.md)
|
||||
(контекст; нормативен настоящий файл)
|
||||
- Историческое ревью ревизии 1 не является доступным артефактом репозитория и
|
||||
|
||||
@@ -43,7 +43,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
|
||||
| [#50](https://github.com/Matysh/houseplan-card/issues/50) Экспорт и импорт конфигурации | [050-config-export-import.md](050-config-export-import.md) | реализовано локально; проверки на следующем пре-релизе |
|
||||
| [#58](https://github.com/Matysh/houseplan-card/issues/58) Vacuum integration coverage — Stage 1 | [058-vacuum-stage1.md](058-vacuum-stage1.md) | в реализации |
|
||||
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Опциональный объёмный 2.5D/изометрический вид | [089-isometric-view.md](089-isometric-view.md) | исследование завершено; продуктовый контекст |
|
||||
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) | ТЗ на ревью, ревизия 3 |
|
||||
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) | ТЗ принято, ревизия 3 |
|
||||
| [#98](https://github.com/Matysh/houseplan-card/issues/98) Единая система пульсаций и активностей устройства | [098-device-pulse-system.md](098-device-pulse-system.md) | реализовано локально; ожидает prerelease/CI gate |
|
||||
|
||||
## P2
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"inventory": "node scripts/inventory.mjs",
|
||||
"audit:config": "node scripts/config-audit.mjs",
|
||||
"benchmark:large-house": "node demo/benchmark_large_house.mjs",
|
||||
"benchmark:large-house-isometric": "node demo/benchmark_large_house.mjs --profile=large-house-isometric-v1",
|
||||
"benchmark:glow": "node demo/benchmark_glow.mjs",
|
||||
"benchmark:compare": "node demo/performance/compare.mjs",
|
||||
"golden:capture": "node demo/golden/run.mjs --mode=capture",
|
||||
|
||||
@@ -4469,11 +4469,12 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _effectiveProjection(): 'flat' | 'iso' {
|
||||
if (this._desiredProjection !== 'iso' || !this._model.length) return 'flat';
|
||||
const key = this._isoSceneKey() || `${this._space}|invalid`;
|
||||
if (this._isoFallback.has(key)) return 'flat';
|
||||
try {
|
||||
const scene = this._isoScene();
|
||||
return isoEffectiveView('iso', scene.key, this._isoFallback);
|
||||
} catch (error) {
|
||||
const key = this._isoSceneKey() || `${this._space}|invalid`;
|
||||
if (!this._isoFallback.has(key)) {
|
||||
this._isoFallback.add(key);
|
||||
const parts = key.split('|');
|
||||
|
||||
+12
-5
@@ -59,16 +59,23 @@ export function validLabsFlag(flag: LabsFlag): boolean {
|
||||
return !!since && !!expires && compareVersion(since, expires) < 0;
|
||||
}
|
||||
|
||||
/** A malformed or ambiguous registry disables the whole mechanism. */
|
||||
export function validLabsRegistry(registry: readonly LabsFlag[]): boolean {
|
||||
const ids = new Set<string>();
|
||||
for (const flag of registry) {
|
||||
if (!validLabsFlag(flag) || ids.has(flag.id)) return false;
|
||||
ids.add(flag.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function liveLabsFlags(
|
||||
version: string, registry: readonly LabsFlag[] = LABS_FLAGS,
|
||||
): ReadonlyMap<string, LabsFlag> {
|
||||
const current = parseVersionCore(version);
|
||||
const live = new Map<string, LabsFlag>();
|
||||
if (!current) return live;
|
||||
const ids = new Set<string>();
|
||||
if (!current || !validLabsRegistry(registry)) return live;
|
||||
for (const flag of registry) {
|
||||
if (!validLabsFlag(flag) || ids.has(flag.id)) continue;
|
||||
ids.add(flag.id);
|
||||
const since = parseVersionCore(flag.since)!;
|
||||
const expires = parseVersionCore(flag.expires)!;
|
||||
if (compareVersion(current, since) >= 0 && compareVersion(current, expires) < 0) {
|
||||
@@ -110,7 +117,7 @@ export function resolveLabs(
|
||||
registry: readonly LabsFlag[] = LABS_FLAGS,
|
||||
): LabsResolution {
|
||||
const live = liveLabsFlags(version, registry);
|
||||
const known = new Set(registry.filter(validLabsFlag).map((flag) => flag.id));
|
||||
const known = new Set(validLabsRegistry(registry) ? registry.map((flag) => flag.id) : []);
|
||||
const active = new Set(storedFlags(storageValue).filter((id) => live.has(id)));
|
||||
let knownUrlOperation = false;
|
||||
const apply = (token: string) => {
|
||||
|
||||
@@ -21,6 +21,11 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
|
||||
assert.equal(Number.isInteger(region.minPixels) && region.minPixels > 0, true, scenario.id);
|
||||
assert.equal(region.minRedBlueDelta > 0, true, scenario.id);
|
||||
}
|
||||
if (scenario.labs) {
|
||||
assert.deepEqual(scenario.labs, ['iso'], scenario.id);
|
||||
assert.equal(scenario.projection, 'iso', scenario.id);
|
||||
assert.equal(scenario.mode, 'view', scenario.id);
|
||||
}
|
||||
if (scenario.sunRayPixels) {
|
||||
assert.equal(Number.isInteger(scenario.sunRayPixels.minPixels)
|
||||
&& scenario.sunRayPixels.minPixels > 0, true, scenario.id);
|
||||
@@ -64,7 +69,9 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
|
||||
'fill-light', 'fill-temp', 'fill-lqi', 'lighting', 'hover', 'zoom-040', 'zoom-250',
|
||||
'warm-remount', 'dialog-mobile', 'color-popover', 'tray-wide', 'tray-medium', 'sun-window',
|
||||
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
|
||||
'backup-full', 'backup-space', 'value-badge-positions'])
|
||||
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
|
||||
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
|
||||
'isometric-large-warm-remount'])
|
||||
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);
|
||||
|
||||
+7
-1
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
LABS_FLAGS, hashSpace, liveLabsFlags, parseVersionCore, resolveLabs, validLabsFlag,
|
||||
validLabsRegistry,
|
||||
} from '../test-build/labs.js';
|
||||
|
||||
const resolve = (search = '', hash = '', storage = null, version = '1.62.0') =>
|
||||
@@ -15,6 +16,12 @@ test('registry metadata and numeric-core lifetime fail closed', () => {
|
||||
assert.equal(liveLabsFlags('1.65.0').has('iso'), false);
|
||||
assert.equal(liveLabsFlags('not-a-version').size, 0);
|
||||
assert.equal(validLabsFlag({ ...LABS_FLAGS[0], since: '1.65.0' }), false);
|
||||
assert.equal(validLabsRegistry([LABS_FLAGS[0], { ...LABS_FLAGS[0] }]), false);
|
||||
assert.equal(liveLabsFlags('1.62.0', [LABS_FLAGS[0], { ...LABS_FLAGS[0] }]).size, 0);
|
||||
assert.deepEqual(resolveLabs(
|
||||
{ search: '?hp-labs=iso', hash: '' }, '["iso"]', '1.62.0',
|
||||
[LABS_FLAGS[0], { ...LABS_FLAGS[0] }],
|
||||
).active, []);
|
||||
});
|
||||
|
||||
test('URL operations are ordered query then hash with off and removal semantics', () => {
|
||||
@@ -48,4 +55,3 @@ test('malformed and unavailable storage are safe', () => {
|
||||
assert.deepEqual(resolve('', '', '{bad').active, []);
|
||||
assert.deepEqual(resolve('?hp-labs=iso', '', undefined).active, ['iso']);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user