Compare commits

...
Author SHA1 Message Date
claude[bot] 3cd238b2d4 docs: review document for #82
Проверка (CI) / Классификация изменённых файлов (push) Successful in 26s
Проверка (CI) / HACS: валидация репозитория (push) Skipped
Проверка (CI) / Hassfest: манифест интеграции (push) Skipped
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Successful in 52s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Skipped
Проверка (CI) / Предполётные проверки: документация, провенанс, процесс (push) Failing after 2m18s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Failing after 7m35s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Skipped
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Skipped
Проверка (CI) / Смоки: все шарды зелёные (push) Skipped
Проверка (CI) / Golden-кадры против принятых эталонов (push) Skipped
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Skipped
Issue: #82
User-Visible: no
2026-08-30 16:11:33 +00:00
claude[bot]andMatysh cc73252aa7 docs: review document for #82
Issue: #82
User-Visible: no
2026-08-30 18:53:11 +03:00
Matysh b5324ce4e3 test: accept inherited dev golden updates
The same eight changes are present on origin/dev and are unrelated to the smooth camera implementation. Review confirmed the catalog actions, toolbar undo/redo controls and dialog help/status icons are the intended current dev UI. Accept only those named Linux candidates; keep 139 existing scenarios unchanged.

Issue: #82
User-Visible: no
Release: v1.70.0-beta.1
Baseline-Reviewed: https://github.com/Matysh/houseplan-card/actions/runs/33319326145
2026-08-30 18:53:11 +03:00
Matysh 9171644f68 docs: accept settled camera captures
Canonical capture: https://github.com/Matysh/houseplan-card/actions/runs/33319234994

Issue: #82
User-Visible: no
2026-08-30 18:53:11 +03:00
Matysh 64f54fd4b0 test: settle animated camera checks
Wait for camera transitions in legacy smoke and golden scenarios, and render the far-object hint state even when fitting is a camera no-op.

Issue: #82
User-Visible: no
2026-08-30 18:53:10 +03:00
Matysh 4e24e2668a docs: accept rebased smooth zoom captures
Canonical capture: https://github.com/Matysh/houseplan-card/actions/runs/33318316300

Issue: #82
User-Visible: no
2026-08-30 18:53:10 +03:00
Matysh 4c4399a729 docs: accept settled smooth zoom captures
Canonical capture: https://github.com/Matysh/houseplan-card/actions/runs/33318010989

Issue: #82
User-Visible: no
2026-08-30 18:53:10 +03:00
Matysh a945ca473b test: wait for settled camera in docs capture
Issue: #82
User-Visible: no
2026-08-30 18:53:10 +03:00
Matysh b3abfc6744 fix: preserve structural adoption guard
Issue: #82
User-Visible: no
2026-08-30 18:53:10 +03:00
Matysh 5440370414 docs: refresh smooth zoom screenshots
Canonical capture: https://github.com/Matysh/houseplan-card/actions/runs/33317712876

Issue: #82
User-Visible: no
2026-08-30 18:53:10 +03:00
Matysh c465eae821 feat: add smooth viewport zoom transitions
Issue: #82
User-Visible: yes
2026-08-30 18:52:54 +03:00
claude[bot]andMatysh 486a5d7bc2 docs: review document for #82
Issue: #82
User-Visible: no
2026-08-30 18:52:54 +03:00
Matysh 7610b32306 docs: actualize smooth zoom specification
Issue: #82
User-Visible: no
2026-08-30 18:52:54 +03:00
Sergey MatyuninandMatysh 8a18e81278 docs: specify smooth viewport zoom
Issue: #82
User-Visible: no
2026-08-30 18:52:54 +03:00
57 changed files with 2169 additions and 455 deletions
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f",
"fingerprint": "099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DdUxnMX-.js",
"houseplan-assets/houseplan-card-DzlQl4_k.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 281785,
"initialViewGzipBytes": 283094,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/de-CeeGlSdH.js",
"houseplan-assets/editor-CWRRIfyY.js",
"houseplan-assets/fr-BIGiyq-a.js",
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/de-Dq2fGpol.js",
"houseplan-assets/editor-DrQ9S89m.js",
"houseplan-assets/fr-Bq4OMxB4.js",
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
],
"lazyGzipBytes": 197610,
"lazyGzipBytes": 197612,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/editor-CWRRIfyY.js",
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/editor-DrQ9S89m.js",
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js"
],
"lazyEditorGzipBytes": 143896,
"lazyEditorGzipBytes": 143897,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
],
"lazyOnboardingGzipBytes": 13791,
"lazyOnboardingGzipBytes": 13789,
"lazyLocaleFiles": [
"houseplan-assets/de-CeeGlSdH.js",
"houseplan-assets/fr-BIGiyq-a.js"
"houseplan-assets/de-Dq2fGpol.js",
"houseplan-assets/fr-Bq4OMxB4.js"
],
"lazyLocaleGzipBytes": 46993,
"lazyLocaleGzipBytes": 46997,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DAhkK64J.js",
"sha256": "d0c0fc8d1a54632e83bf0832717f9cad9aef21e03740f39a68e254b9fa2de006",
"path": "houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"sha256": "383eea583e6ee787be10c7766420bbff1738f466827bcada4dbb2fbf1de24505",
"rawBytes": 20636,
"gzipBytes": 7070,
"gzipBytes": 7071,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-CeeGlSdH.js",
"sha256": "833468501744eb020baadd4664c352cde634c2324280ad32ed4c3c4353d44857",
"path": "houseplan-assets/de-Dq2fGpol.js",
"sha256": "17cca86ed13e257b06567609834707e975e9dacb38e495208a5bceedf4e7da8f",
"rawBytes": 78310,
"gzipBytes": 23718,
"gzipBytes": 23721,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-CWRRIfyY.js",
"sha256": "0abaa66a50efc8648f0eddc9e91b9cbd52213f70741e5193fbee331b28e2b3a7",
"path": "houseplan-assets/editor-DrQ9S89m.js",
"sha256": "0b137accab6d6ed51f44aed3d4d2b1ff444b5307ac9c11ff0cb9535cfd25bb2c",
"rawBytes": 3826,
"gzipBytes": 1581,
"gzipBytes": 1582,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BIGiyq-a.js",
"sha256": "a553eff93f33804b66b2e1be445960d38c4222f03627e78b0514299989ba9a51",
"path": "houseplan-assets/fr-Bq4OMxB4.js",
"sha256": "da0d137d42243e36315ed6ad73197210ea12cf672cf14eb7cb6320594c9aec56",
"rawBytes": 80482,
"gzipBytes": 23275,
"gzipBytes": 23276,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DdUxnMX-.js",
"sha256": "eea3b14c28ac1f495e685d7b708ac3ee9c585efdc2cefc1454d962454cbfa91b",
"rawBytes": 996305,
"gzipBytes": 280990,
"path": "houseplan-assets/houseplan-card-DzlQl4_k.js",
"sha256": "ac14483abe54719e12506df7eef1f26f4fd570c92600ea781159e445249ecca9",
"rawBytes": 1001346,
"gzipBytes": 282298,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-CeeGlSdH.js",
"houseplan-assets/editor-CWRRIfyY.js",
"houseplan-assets/fr-BIGiyq-a.js",
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
"houseplan-assets/de-Dq2fGpol.js",
"houseplan-assets/editor-DrQ9S89m.js",
"houseplan-assets/fr-Bq4OMxB4.js",
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
"sha256": "cc614c3f22050e61668b34887dd6bfe2be7f84619b4b57202674f45c5790b42f",
"path": "houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
"sha256": "aeef2fb0c3cec33286d9aee59488c69713483da5e93d5a47b28b67e5637354e6",
"rawBytes": 524015,
"gzipBytes": 135245,
"gzipBytes": 135244,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js",
"sha256": "4cf023f49c8315f3ca2866086169677326c450ad96aac0a36f299f811df7f5bc",
"path": "houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js",
"sha256": "d7aba6728494fdc25e17a614eba3e91bb8132ee8248d8970fe6030565ec8c36b",
"rawBytes": 27120,
"gzipBytes": 6721,
"gzipBytes": 6718,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "b526f327c3c51c30c72fc2fdef92c48f82a9f6334839ee24d07120895d46386f",
"sha256": "2bdf546fd8a2b8fe97577f166d3dd1720dc7777201accb899856e1ea3e96c14d",
"rawBytes": 1183,
"gzipBytes": 795,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-DdUxnMX-.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-DzlQl4_k.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-DdUxnMX-.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-DAhkK64J.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-DzlQl4_k.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-CJ3kZCi2.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,r="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:p(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:r,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=r(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:d,tempMax:_,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";try{await import("./houseplan-assets/houseplan-card-DdUxnMX-.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";try{await import("./houseplan-assets/houseplan-card-DzlQl4_k.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+10
View File
@@ -86,6 +86,15 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const card = window.__goldenCard;
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
const settleCamera = async () => {
const started = performance.now();
do { await frame(); }
while (card._cameraTransition?.active && performance.now() - started < 1200);
if (card._cameraTransition?.active)
throw new Error(`documentation camera did not settle: ${current.id}`);
// One quiet pair proves that the settled reactive frame reached layout.
await frame();
};
if (current.title) {
card.setConfig({ ...card._config, title: current.title });
@@ -142,6 +151,7 @@ const applyDocumentationState = (page, scenario) => page.evaluate(async (current
throw new Error('documentation viewport does not show the device presentation preview');
}
await settleCamera();
return { dialog: !!card.renderRoot.querySelector('hp-dialog') };
}, scenario);
+11 -11
View File
@@ -1,11 +1,11 @@
{
"schema": 1,
"matrixVersion": 53,
"acceptedAt": "2026-08-30T11:29:48.257Z",
"sourceFingerprint": "c63e55839e4e87dfffefae928d34c030e38064a799953b0ffd7b14052ef53d85",
"acceptedAt": "2026-08-30T15:29:10.420Z",
"sourceFingerprint": "099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347",
"chromium": "151.0.7922.34",
"witnesses": {
"count": 133,
"count": 96,
"floor": 10
},
"scenarios": {
@@ -66,13 +66,13 @@
"opening-symbol-flip-pairs-light": "6d780d1d06909e6e5a7ebb35987b9681d6902d78408e974dfe2fe587521990ce",
"isometric-opening-symbol-parity-dark": "1e254ed43da00a16d3048e9a2d29db0a43c7790991b1006ecb7205d002bd1f63",
"geometry-devices-editor-dark": "a9a9d22b37e03fde37120889a4d9aa50b9d318a31e416504f74e2905b5d947ff",
"device-inbox-desktop-en-light": "a4313ccac566e20dcd4c8b66fe8341b58e62a6f39657cb9fca1cfceabda4ad94",
"device-inbox-desktop-ru-dark": "dc251365449a124a4887277945ea43afb822a7e3f1e2355eb191dba14fdd69eb",
"device-inbox-narrow-ru-dark": "68771e55e12a0860b4c509d2bf780211e1f5dca42ff62a1627b6a522d063c808",
"device-inbox-desktop-en-light": "23fef91974a18f12a71858e67ce981e7b0d34892883cb1c2884cd2695a38bcfc",
"device-inbox-desktop-ru-dark": "95a4c0b93a6150b145b967b55344727287843be09bc77b25b23fb0fdaf911142",
"device-inbox-narrow-ru-dark": "061031091fd29a3be0af435e84f1e3fe7a4e2a485e7fefc617aa24b5ec1cdb39",
"geometry-decor-editor-dark": "4a4d40844f4f92b0e1baf169a25f8a15a507682cea677c631dc57f68e31e373f",
"tray-wide-selection-en": "056f3b54d60bba83ef313f9c12fe697ea6a0b3d48db0470735394ee0d3f72c39",
"tray-wide-tool-ru": "f8e050c96cb3e78f1ab8d0b8b276b3d154950e795c5b620148b9e395330cffe9",
"tray-medium-group-en": "4513abc13f1bc24e4d04f14445069c8ce9785dc3fddcdc99bcee41b5eadb8d53",
"tray-medium-group-en": "9b74b675760be423e410176c72b2aa413c19ba7411ddd2d2d0324cf5ba2e1dd6",
"tray-medium-selection-ru": "44de98a53d7de665daf2a3f466c281caa66ef6d6cd2941125e2cbfd975f8c8f4",
"tray-narrow-palette-en": "392eeac327c899add9792e356d75f48ae541c38bd1c2ace6bdde8e9dcdf17fd6",
"furniture-categories-light": "13ab156ce69ff67f82b0aaa1e755d7b2ed63bda2a06f248f56d713c0d88e6fb7",
@@ -134,18 +134,18 @@
"large-house-zoom-250-dark": "0a74099e265b38f376bb7bfd86d4a57c5d493ac3870478a7d55c48cf99e8f892",
"large-house-warm-remount-dark": "1c14dba8cdbc4628984091d395c1ac14faead1d43338e44aa3cb57976e519df6",
"device-dialog-desktop-en": "e142521eae1bd40861c1d92eadbcfd6d88aefa689cd5ee9b4c1b6378c8ce712c",
"device-dialog-mobile-ru": "7921b7d5e2c082fc4dd5053607a8228c2e63e0e72a401d86153effd54c2c6ca0",
"device-dialog-desktop-de": "ac3f657e072006f5c1686d89da61df922991a04f69d2d7994a03d1ba93532681",
"device-dialog-mobile-ru": "bde342feb5667a76884c081a4d9dd9bbacbbf7158f48f1c68322408bdde3d053",
"device-dialog-desktop-de": "ef620a0b5d14f24d0d5021bcd720fe1531c84832442cf0f7964397450ed8ebc3",
"german-view-mobile-light": "9496071b11f510f1c851a9c60583a96bfe768c1fd1e12a2b1b9183dee7e453a8",
"toggle-entity-dialog-desktop-en": "e192bab83ae99bfb8d33046921f0a3009ebaa8074bd30971ab41a1eeac69ea0d",
"toggle-entity-dialog-mobile-ru": "15ac99162f51b5112896fd879bc5645d94593dd839d400e8c7792ebc4340829d",
"toggle-entity-dialog-mobile-ru": "5143ad6846bc605dc52a97abe1b6593596fb25bb80a121f3c6279f1b7f421fee",
"device-help-popover-light-ru": "259a50065b7f09d07db656b6cfe79d1de34397665ff8dd8a8e361f6f29a7ea6a",
"decor-color-popover-mobile-ru": "606c41d53f9a996b24990032505180159ce032e2b4f14dfb214c79a8c4b85702",
"decor-color-popover-desktop-en": "f626f246c8e29d1b1e5e067e0923438eb1222606388a33c3071e86307b4ca130",
"general-color-popover-desktop-en": "008865918113e05847a7ad8ae0fdc068b5326acf1c591d855806b333084978a3",
"settings-help-zoom-200-en-light": "bb1192a52bab5ebc6984a5d799a32d05f9ce374ce6b493f5f618b639eedce8d5",
"settings-help-zoom-200-ru-dark": "13e7fdd19017c38a5659394e622ff54ac8a426f9cb6e85b3248864767aa9f42b",
"device-ripple-color-popover-mobile-ru": "4f2365818f46fe1e2b401e5835f5d29ea9c41b288d894025d7512db92f579762",
"device-ripple-color-popover-mobile-ru": "01c538f8b504ab9f5e8d64cb4cbbf3131c5de9b0909c996e2d61f4523801127c",
"space-room-color-popover-desktop-ru": "ff0e633c7fbedfa04883792682d8e407aa0633bbd46ed12e0ab8bc2207be9ccb",
"backup-full-preview-desktop-en": "6b68188269fdbaddaf4f2ce8fc0d10ea41e050f0c08cdbd9507548871a3786ef",
"backup-plan-only-export-desktop-en": "805fb7cd15959a7eee0b17150c4417e335f1b1030a438f7051c2b006a5f7d4f4",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

+8 -1
View File
@@ -742,6 +742,11 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await frame();
};
const settleCamera = async (card) => {
await until(() => !card._cameraTransition?.active, 1500);
await card.updateComplete;
await frame();
};
window.__goldenCard?.remove?.();
window.__goldenEditor?.remove?.();
window.__card?.remove?.();
@@ -1711,7 +1716,9 @@ export async function prepareGoldenScenario(page, scenario) {
}
}
await document.fonts?.ready;
await frame();
// Camera motion is intentionally visible in production, but reviewed
// goldens own the settled UI. Never capture a timing-dependent RAF frame.
await settleCamera(card);
return {
space: card._space,
mode: card._mode,
+6
View File
@@ -44,6 +44,12 @@ const load = (cfg, layout, space) => page.evaluate(async ([c2, l2, sp]) => {
await c.updateComplete;
c._resetZoom();
await c.updateComplete;
const started = performance.now();
while (c._cameraTransition?.active) {
if (performance.now() - started > 1000) throw new Error('camera transition did not settle');
await new Promise((r) => requestAnimationFrame(r));
}
await c.updateComplete;
}, [cfg, layout, space]);
// ---------------------------------------------------------------- 01: hidden
+29 -13
View File
@@ -12,6 +12,18 @@ const SHOTS = process.env.HP_SHOTS || '';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = {};
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
await page.evaluate(() => {
window.__waitForCamera = async (card = window.__card) => {
const started = performance.now();
while (card?._cameraTransition?.active) {
if (performance.now() - started > 1000) throw new Error('camera transition did not settle');
await new Promise((r) => requestAnimationFrame(r));
}
await card?.updateComplete;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
await card?.updateComplete;
};
});
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
@@ -61,7 +73,7 @@ const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
c.requestUpdate();
await c.updateComplete;
c._resetZoom();
await c.updateComplete;
await window.__waitForCamera(c);
}, [cfg, layout]);
await loadFar(FAR_CFG, FAR_LAYOUT);
@@ -181,7 +193,7 @@ Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
sr.querySelector('.farhint button').click();
await c.updateComplete;
await window.__waitForCamera(c);
const o = {};
const v = c._viewOr(c._baseVb());
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
@@ -194,10 +206,11 @@ Object.assign(out, await page.evaluate(async () => {
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
await loadFar(FAR_CFG, FAR_LAYOUT);
await raf();
Object.assign(out, await page.evaluate(() => {
Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const o = {};
c._resetZoom();
await window.__waitForCamera(c);
const fit = { ...c._viewOr(c._baseVb()) };
c._applyView(0.001);
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
@@ -205,6 +218,7 @@ Object.assign(out, await page.evaluate(() => {
c._applyView(50);
o.zoomInUnchanged = c._zoom === 8;
c._resetZoom();
await window.__waitForCamera(c);
return o;
}));
@@ -213,7 +227,7 @@ Object.assign(out, await page.evaluate(async () => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const o = {};
c._resetZoom(); await c.updateComplete;
c._resetZoom(); await window.__waitForCamera(c);
const fit = { ...c._view };
// pan most of a screen to the right: nothing pins the content over the scene
c._view = c._clampView({ ...fit, x: fit.x + fit.w * 0.9 }, fit);
@@ -225,13 +239,13 @@ Object.assign(out, await page.evaluate(async () => {
o.homeArrowAppears = !!arrow;
if (arrow) {
arrow.click();
await c.updateComplete;
await window.__waitForCamera(c);
o.homeArrowFitsBack = Math.abs(c._view.x - fit.x) < 2 && Math.abs(c._view.w - fit.w) < 2;
await c.updateComplete;
o.homeArrowGone = !sr.querySelector('.homearrow');
}
// and it is NOT there while the plan is on screen
c._resetZoom(); c.requestUpdate(); await c.updateComplete;
c._resetZoom(); await window.__waitForCamera(c);
o.noHomeArrowWhenVisible = !sr.querySelector('.homearrow');
return o;
}));
@@ -255,13 +269,13 @@ const farIcon = await page.evaluate(async () => {
return Math.round(el.getBoundingClientRect().width * 100) / 100;
};
const o = {};
c._resetZoom(); await c.updateComplete;
c._resetZoom(); await window.__waitForCamera(c);
const at1 = size();
c._applyView(4); await c.updateComplete;
const at4 = size();
c._applyView(1 / 3); await c.updateComplete;
const atOut = size();
c._resetZoom(); await c.updateComplete;
c._resetZoom(); await window.__waitForCamera(c);
o.iconGrowsWithZoomIn = at1 > 4 && Math.abs(at4 / at1 - 4) < 0.05;
o.iconShrinksWithZoomOut = Math.abs(atOut / at1 - 1 / 3) < 0.02;
if (!o.iconGrowsWithZoomIn || !o.iconShrinksWithZoomOut) console.log('icon px', at1, at4, atOut);
@@ -286,7 +300,7 @@ Object.assign(out, await page.evaluate(async () => {
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
c.requestUpdate(); await c.updateComplete;
c._resetZoom(); await c.updateComplete;
c._resetZoom(); await window.__waitForCamera(c);
const b = c._baseVb();
// identical to what contentBounds produced before: the content bbox
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
@@ -327,7 +341,7 @@ out.farIconNotDegenerate = await page.evaluate(async (farPx) => {
c._maybeRebuildDevices?.();
c._defPos = c._defaultPositions();
c.requestUpdate(); await c.updateComplete;
c._resetZoom(); await c.updateComplete;
c._resetZoom(); await window.__waitForCamera(c);
const el = sr.querySelector('.devlayer .dev');
const px = el.getBoundingClientRect().width - 2;
if (!(px > 4 && Math.abs(px / farPx - 1) < 0.3)) console.log('icon px near/far', px, farPx);
@@ -347,7 +361,7 @@ if (SHOTS) {
await page.evaluate(async () => {
const c = window.__card;
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
await c.updateComplete;
await window.__waitForCamera(c);
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
@@ -356,13 +370,15 @@ if (SHOTS) {
await page.evaluate(async () => {
const c = window.__card;
c._setMode('plan'); await c.updateComplete;
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
c._resetZoom(); await window.__waitForCamera(c);
c._applyView(1 / 3); await c.updateComplete;
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
await page.evaluate(async () => {
const c = window.__card;
c._resetZoom(); c._applyView(4); await c.updateComplete;
c._resetZoom(); await window.__waitForCamera(c);
c._applyView(4); await c.updateComplete;
});
await raf();
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
+5
View File
@@ -37,6 +37,11 @@ const res = await page.evaluate(async () => {
c._lastTap = Date.now() - 100;
stage.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, pointerId: 3, clientX: 500, clientY: 300 }));
stage.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true, pointerId: 3, clientX: 501, clientY: 300 }));
const resetStarted = performance.now();
while (c._cameraTransition?.active) {
if (performance.now() - resetStarted > 1000) throw new Error('double-tap camera transition did not settle');
await new Promise((r) => requestAnimationFrame(r));
}
await c.updateComplete;
out.dblTapResets = c._zoom === 1;
// 6) локальный множитель иконок применяется
+3
View File
@@ -111,6 +111,9 @@ const out = await page.evaluate(async () => {
k._lastTap = Date.now() - 100;
fire('pointerdown', 58, 500, 300);
fire('pointerup', 58, 501, 300);
const resetStarted = performance.now();
do { await new Promise((resolve) => requestAnimationFrame(resolve)); }
while (k._cameraTransition.active && performance.now() - resetStarted < 1000);
await k.updateComplete;
o.doubleTapStillResetsZoom = k._zoom === 1;
o.doubleTapLeavesNoLock = k._panLock === null;
+250
View File
@@ -0,0 +1,250 @@
// #82: every discrete camera command uses one short, retargetable transition;
// direct pointer gestures and structural/lifecycle changes remain atomic.
import { launchColdView, checkAll, finish } from './serve.mjs';
const { page, browser } = await launchColdView({ width: 900, height: 720 });
const out = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const stage = () => root().querySelector('.stage');
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const camera = () => ({ zoom: card._zoom, view: { ...card._view } });
const close = (a, b, epsilon = 1e-4) => Math.abs(a - b) <= epsilon;
const sameView = (a, b, epsilon = 1e-4) => close(a.x, b.x, epsilon)
&& close(a.y, b.y, epsilon) && close(a.w, b.w, epsilon)
&& close(a.h, b.h, epsilon);
const settle = async () => {
const samples = [];
const started = performance.now();
do {
await frame();
samples.push(camera());
} while (card._cameraTransition.active && performance.now() - started < 1200);
await card.updateComplete;
return samples;
};
const directBaseline = (zoom = 1) => {
const vb = card._baseVb();
card._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
};
card._setMode('view');
await card.updateComplete;
directBaseline(1);
const originalSave = card._saveZoom.bind(card);
let saveCount = 0;
card._saveZoom = () => { saveCount++; originalSave(); };
// Toolbar button: intermediate pixels exist and exact legacy target wins.
const buttonExpected = card._cameraTargetAt(
stage().clientWidth / 2, stage().clientHeight / 2, 1.4,
).target;
card._stepZoom(1);
const buttonSamples = await settle();
const buttonFinal = camera();
const buttonSaves = saveCount;
// Wheel anchor is calculated from the actually painted frame. A reversal
// replaces the target and RAF rather than queueing a second transition.
directBaseline(1);
saveCount = 0;
const rect = stage().getBoundingClientRect();
const anchor = { x: rect.width * 0.31, y: rect.height * 0.67 };
stage().dispatchEvent(new WheelEvent('wheel', {
bubbles: true, cancelable: true, deltaY: -100,
clientX: rect.left + anchor.x, clientY: rect.top + anchor.y,
}));
const firstToken = card._cameraTransition.state?.token;
await frame();
const presentedCameraBeforeReverse = camera();
const presentedBeforeReverse = presentedCameraBeforeReverse.zoom;
const worldAtReverse = {
x: presentedCameraBeforeReverse.view.x
+ anchor.x / rect.width * presentedCameraBeforeReverse.view.w,
y: presentedCameraBeforeReverse.view.y
+ anchor.y / rect.height * presentedCameraBeforeReverse.view.h,
};
stage().dispatchEvent(new WheelEvent('wheel', {
bubbles: true, cancelable: true, deltaY: 100,
clientX: rect.left + anchor.x, clientY: rect.top + anchor.y,
}));
const secondToken = card._cameraTransition.state?.token;
const reverseFrom = card._cameraTransition.state?.from.zoom;
const wheelSamples = await settle();
const worldAfter = {
x: card._view.x + anchor.x / rect.width * card._view.w,
y: card._view.y + anchor.y / rect.height * card._view.h,
};
const wheelSaves = saveCount;
// A real pointerdown freezes the presented camera: the obsolete target may
// not settle later under a drag/select gesture.
directBaseline(1);
card._stepZoom(1);
await frame();
const pointerPresented = camera();
stage().dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, pointerId: 82, pointerType: 'mouse', buttons: 1,
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
}));
await wait(260);
const pointerFrozen = camera();
stage().dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, pointerId: 82, pointerType: 'mouse', buttons: 0,
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
}));
// Pinch's production primitive is direct and cancels a running tween.
directBaseline(1);
card._stepZoom(1);
await frame();
card._zoomAt(rect.width / 2, rect.height / 2, 2);
const pinchDirect = camera();
await wait(240);
const pinchAfterDeadline = camera();
// Fit/home/double-tap share the same exact fit but retain their observable
// reason, so their entry points cannot silently regress to a snap.
const reasons = {};
for (const reason of ['fit', 'home', 'double-tap']) {
directBaseline(2);
if (reason === 'home') card._fitAll('home');
else if (reason === 'fit') card._fitAll('fit');
else card._resetZoom('double-tap');
reasons[reason] = {
liveReason: card._cameraTransition.state?.reason,
samples: (await settle()).map((sample) => sample.zoom),
final: card._zoom,
};
}
card._showFar = false;
card._frame = null;
directBaseline(2);
card._fitFar();
const farFitReason = card._cameraTransition.state?.reason;
const farFitSamples = await settle();
const farFitFinal = card._zoom;
// Reduced motion before and during a command is authoritative immediately.
directBaseline(1);
card._reducedMotion = true;
card._stepZoom(1);
const reducedBefore = { active: card._cameraTransition.active, zoom: card._zoom };
card._reducedMotion = false;
directBaseline(1);
card._stepZoom(1);
await frame();
card._onMotionChange({ matches: true });
const reducedDuring = { active: card._cameraTransition.active, zoom: card._zoom };
card._reducedMotion = false;
// Projection owns an atomic structural conversion and cancels the camera.
card._labs = { ...card._labs, active: [...new Set([...card._labs.active, 'iso'])] };
card._viewPreference = { ...card._viewPreference, [card._space]: 'flat' };
directBaseline(1);
card._stepZoom(1);
await frame();
card._setProjection('iso');
const projectionCancelled = !card._cameraTransition.active;
await card.updateComplete;
await frame();
await frame();
const isoExpected = card._cameraTargetAt(
stage().clientWidth / 2, stage().clientHeight / 2, card._zoom * 1.4,
).target;
card._stepZoom(1);
await settle();
const isoFinal = camera();
card._setProjection('flat');
// Hidden commits the user's target and no old RAF resumes later.
directBaseline(1);
card._stepZoom(1);
card._pageVisibility({ kind: 'hidden' });
const hiddenCommitted = { active: card._cameraTransition.active, zoom: card._zoom };
await wait(240);
const hiddenAfterDeadline = card._zoom;
// Exact fit is a no-op: neither a RAF nor persistence write is created.
card._reducedMotion = true;
card._resetZoom('fit');
card._reducedMotion = false;
saveCount = 0;
card._resetZoom('fit');
const fitNoop = { active: card._cameraTransition.active, saves: saveCount };
card._applyView(8);
saveCount = 0;
card._stepZoom(1);
const maxNoop = { active: card._cameraTransition.active, saves: saveCount };
card._applyView(1 / 3);
saveCount = 0;
card._stepZoom(-1);
const minNoop = { active: card._cameraTransition.active, saves: saveCount };
const glowBefore = {
spots: root().querySelectorAll('[data-glow-spot]').length,
blend: root().querySelector('.glow-pools')?.getAttribute('data-blend') || '',
rooms: root().querySelectorAll('.room').length,
base: [...card._baseVb()],
};
card._stepZoom(1);
await settle();
const glowAfter = {
spots: root().querySelectorAll('[data-glow-spot]').length,
blend: root().querySelector('.glow-pools')?.getAttribute('data-blend') || '',
rooms: root().querySelectorAll('.room').length,
base: [...card._baseVb()],
};
const resources = performance.getEntriesByType('resource')
.map((entry) => new URL(entry.name).pathname);
return {
buttonHasIntermediateFrame: buttonSamples.some((sample) =>
sample.zoom > 1.001 && sample.zoom < buttonExpected.zoom - 0.001),
buttonSettlesAtExactTarget: close(buttonFinal.zoom, buttonExpected.zoom)
&& sameView(buttonFinal.view, buttonExpected.viewBox),
buttonPersistsOnce: buttonSaves === 1,
wheelRetargetsRunningTween: Number.isFinite(firstToken) && Number.isFinite(secondToken)
&& firstToken !== secondToken && close(reverseFrom, presentedBeforeReverse, 0.02),
wheelHasIntermediateFrames: wheelSamples.some((sample) => sample.zoom > 1.001),
wheelReversalKeepsAnchor: Math.hypot(
(worldAfter.x - worldAtReverse.x) / card._view.w * rect.width,
(worldAfter.y - worldAtReverse.y) / card._view.h * rect.height,
) <= 0.5,
wheelStreamPersistsOnce: wheelSaves === 1,
pointerdownFreezesPresentedFrame: !card._cameraTransition.active
&& close(pointerFrozen.zoom, pointerPresented.zoom, 0.001)
&& sameView(pointerFrozen.view, pointerPresented.view, 0.001),
pinchIsDirectWithoutPostAnimation: close(pinchDirect.zoom, 2)
&& !card._cameraTransition.active
&& close(pinchAfterDeadline.zoom, 2)
&& sameView(pinchAfterDeadline.view, pinchDirect.view),
fitHomeAndDoubleTapAnimate: Object.entries(reasons).every(([reason, value]) =>
value.liveReason === reason
&& value.samples.some((zoom) => zoom > 1.001 && zoom < 1.999)
&& close(value.final, 1)),
farFitUsesTheSameTransition: farFitReason === 'fit'
&& farFitSamples.some((sample) => sample.zoom > 1.001 && sample.zoom < 1.999)
&& close(farFitFinal, 1),
reducedMotionIsImmediate: !reducedBefore.active && close(reducedBefore.zoom, 1.4)
&& !reducedDuring.active && close(reducedDuring.zoom, 1.4),
projectionCancelsAndIsoSettlesExactly: projectionCancelled
&& close(isoFinal.zoom, isoExpected.zoom)
&& sameView(isoFinal.view, isoExpected.viewBox),
hiddenCommitsTargetOnce: !hiddenCommitted.active && close(hiddenCommitted.zoom, 1.4)
&& close(hiddenAfterDeadline, hiddenCommitted.zoom),
exactLimitsAndFitAreNoops: !fitNoop.active && fitNoop.saves === 0
&& !maxNoop.active && maxNoop.saves === 0
&& !minNoop.active && minNoop.saves === 0,
glowAndStructuralFrameStayStable: JSON.stringify(glowAfter) === JSON.stringify(glowBefore),
coldViewDoesNotLoadEditorRuntime: resources.every((path) =>
!/houseplan-editor-runtime-/.test(path)),
diagnostics: { projectionCancelled, isoExpected, isoFinal, glowBefore, glowAfter },
};
});
const { diagnostics, ...checks } = out;
checkAll(checks);
await finish(browser, out);
+56 -56
View File
@@ -1,125 +1,125 @@
{
"schema": 1,
"fingerprint": "a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f",
"fingerprint": "099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347",
"entry": "houseplan-card.js",
"initialViewFiles": [
"houseplan-assets/houseplan-card-DdUxnMX-.js",
"houseplan-assets/houseplan-card-DzlQl4_k.js",
"houseplan-card.js"
],
"initialViewGzipBytes": 281785,
"initialViewGzipBytes": 283094,
"lazyFiles": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/de-CeeGlSdH.js",
"houseplan-assets/editor-CWRRIfyY.js",
"houseplan-assets/fr-BIGiyq-a.js",
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/de-Dq2fGpol.js",
"houseplan-assets/editor-DrQ9S89m.js",
"houseplan-assets/fr-Bq4OMxB4.js",
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
],
"lazyGzipBytes": 197610,
"lazyGzipBytes": 197612,
"lazyEditorFiles": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/editor-CWRRIfyY.js",
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/editor-DrQ9S89m.js",
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js"
],
"lazyEditorGzipBytes": 143896,
"lazyEditorGzipBytes": 143897,
"lazyOnboardingFiles": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
],
"lazyOnboardingGzipBytes": 13791,
"lazyOnboardingGzipBytes": 13789,
"lazyLocaleFiles": [
"houseplan-assets/de-CeeGlSdH.js",
"houseplan-assets/fr-BIGiyq-a.js"
"houseplan-assets/de-Dq2fGpol.js",
"houseplan-assets/fr-Bq4OMxB4.js"
],
"lazyLocaleGzipBytes": 46993,
"lazyLocaleGzipBytes": 46997,
"files": [
{
"path": "houseplan-assets/backdrop-pick-DAhkK64J.js",
"sha256": "d0c0fc8d1a54632e83bf0832717f9cad9aef21e03740f39a68e254b9fa2de006",
"path": "houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"sha256": "383eea583e6ee787be10c7766420bbff1738f466827bcada4dbb2fbf1de24505",
"rawBytes": 20636,
"gzipBytes": 7070,
"gzipBytes": 7071,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/de-CeeGlSdH.js",
"sha256": "833468501744eb020baadd4664c352cde634c2324280ad32ed4c3c4353d44857",
"path": "houseplan-assets/de-Dq2fGpol.js",
"sha256": "17cca86ed13e257b06567609834707e975e9dacb38e495208a5bceedf4e7da8f",
"rawBytes": 78310,
"gzipBytes": 23718,
"gzipBytes": 23721,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/editor-CWRRIfyY.js",
"sha256": "0abaa66a50efc8648f0eddc9e91b9cbd52213f70741e5193fbee331b28e2b3a7",
"path": "houseplan-assets/editor-DrQ9S89m.js",
"sha256": "0b137accab6d6ed51f44aed3d4d2b1ff444b5307ac9c11ff0cb9535cfd25bb2c",
"rawBytes": 3826,
"gzipBytes": 1581,
"gzipBytes": 1582,
"isEntry": false,
"imports": [
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/fr-BIGiyq-a.js",
"sha256": "a553eff93f33804b66b2e1be445960d38c4222f03627e78b0514299989ba9a51",
"path": "houseplan-assets/fr-Bq4OMxB4.js",
"sha256": "da0d137d42243e36315ed6ad73197210ea12cf672cf14eb7cb6320594c9aec56",
"rawBytes": 80482,
"gzipBytes": 23275,
"gzipBytes": 23276,
"isEntry": false,
"imports": [],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-card-DdUxnMX-.js",
"sha256": "eea3b14c28ac1f495e685d7b708ac3ee9c585efdc2cefc1454d962454cbfa91b",
"rawBytes": 996305,
"gzipBytes": 280990,
"path": "houseplan-assets/houseplan-card-DzlQl4_k.js",
"sha256": "ac14483abe54719e12506df7eef1f26f4fd570c92600ea781159e445249ecca9",
"rawBytes": 1001346,
"gzipBytes": 282298,
"isEntry": false,
"imports": [],
"dynamicImports": [
"houseplan-assets/de-CeeGlSdH.js",
"houseplan-assets/editor-CWRRIfyY.js",
"houseplan-assets/fr-BIGiyq-a.js",
"houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
"houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js"
"houseplan-assets/de-Dq2fGpol.js",
"houseplan-assets/editor-DrQ9S89m.js",
"houseplan-assets/fr-Bq4OMxB4.js",
"houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
"houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js"
]
},
{
"path": "houseplan-assets/houseplan-editor-runtime-DmHZCYVI.js",
"sha256": "cc614c3f22050e61668b34887dd6bfe2be7f84619b4b57202674f45c5790b42f",
"path": "houseplan-assets/houseplan-editor-runtime-C6fRvS15.js",
"sha256": "aeef2fb0c3cec33286d9aee59488c69713483da5e93d5a47b28b67e5637354e6",
"rawBytes": 524015,
"gzipBytes": 135245,
"gzipBytes": 135244,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
},
{
"path": "houseplan-assets/houseplan-onboarding-runtime-zkpLLWyG.js",
"sha256": "4cf023f49c8315f3ca2866086169677326c450ad96aac0a36f299f811df7f5bc",
"path": "houseplan-assets/houseplan-onboarding-runtime-HmSdEh0z.js",
"sha256": "d7aba6728494fdc25e17a614eba3e91bb8132ee8248d8970fe6030565ec8c36b",
"rawBytes": 27120,
"gzipBytes": 6721,
"gzipBytes": 6718,
"isEntry": false,
"imports": [
"houseplan-assets/backdrop-pick-DAhkK64J.js",
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/backdrop-pick-CJ3kZCi2.js",
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
},
{
"path": "houseplan-card.js",
"sha256": "b526f327c3c51c30c72fc2fdef92c48f82a9f6334839ee24d07120895d46386f",
"sha256": "2bdf546fd8a2b8fe97577f166d3dd1720dc7777201accb899856e1ea3e96c14d",
"rawBytes": 1183,
"gzipBytes": 795,
"gzipBytes": 796,
"isEntry": true,
"imports": [
"houseplan-assets/houseplan-card-DdUxnMX-.js"
"houseplan-assets/houseplan-card-DzlQl4_k.js"
],
"dynamicImports": []
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-DdUxnMX-.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";import{b as e,l as o,t,d7 as s,A as a,d8 as i,d9 as l,da as n,E as r,c}from"./houseplan-card-DzlQl4_k.js";class h extends e{constructor(){super(...arguments),this._spaces=null,this._spacesLoading=!1,this._spacesAuthoritative=!1}setConfig(e){this._config=e}async _loadSpaces(){if(!this._spaces&&!this._spacesLoading&&this.hass){this._spacesLoading=!0;try{const e=await this.hass.callWS({type:"houseplan/config/get"});this._spaces=(e?.config?.spaces||[]).map(e=>({value:e.id,label:e.title||e.id})),this._spacesAuthoritative=!0}catch{this._spaces=[],this._spacesAuthoritative=!1}finally{this._spacesLoading=!1}}}get _lang(){return o(this.hass,this._config?.language)}get _floorToken(){const e=this._config?.floor;return"number"==typeof e?`__houseplan_yaml_floor_index__:${String(e)}`:null}get _formData(){const e={...this._config},o=this._floorToken;return o?e.floor=o:Object.prototype.hasOwnProperty.call(e,"floor")||(e.floor=""),e}get _schema(){const e=this._spaces||[],o=this._lang,a=[{value:"",label:t(o,"editor.floor_none")}],i=this._floorToken;i&&a.push({value:i,label:t(o,"editor.floor_index",{index:String(this._config?.floor)})});const l="string"==typeof this._config?.floor?this._config.floor:"";l&&!e.some(e=>e.value===l)&&a.push({value:l,label:l}),a.push(...e);const n="string"==typeof this._config?.default_floor?this._config.default_floor:"",r=[...e];return n&&!e.some(e=>e.value===n)&&r.unshift({value:n,label:n}),[{name:"title",selector:{text:{}}},{name:"floor",selector:{select:{mode:"dropdown",options:a}}},e.length?{name:"default_floor",selector:{select:{mode:"dropdown",options:r}}}:{name:"default_floor",selector:{text:{}}},{name:"language",selector:{select:{mode:"dropdown",options:s(t(o,"editor.lang_auto"),this._config?.language)}}},{name:"icon_size",selector:{number:{min:1,max:6,step:.1,mode:"box"}}},{name:"show_temperature",selector:{boolean:{}}},{name:"live_states",selector:{boolean:{}}},{name:"show_signal",selector:{boolean:{}}},{name:"kiosk",selector:{boolean:{}}},{name:"cycle",selector:{number:{min:0,max:3600,step:5,mode:"box"}}}]}render(){if(!this.hass||!this._config)return a;const e=i(this,l,o(this.hass,this._config.language));if("cold"===e)return n();if("warm"===e)return r;this._loadSpaces();const s=this._lang,h={title:t(s,"editor.title"),floor:t(s,"editor.floor"),default_floor:t(s,"editor.default_floor"),language:t(s,"editor.language"),icon_size:t(s,"editor.icon_size"),show_temperature:t(s,"editor.show_temperature"),live_states:t(s,"editor.live_states"),show_signal:t(s,"editor.show_signal"),kiosk:t(s,"editor.kiosk"),cycle:t(s,"editor.cycle")},f=this._schema,_=function(e,o,t){if(!t||null===o)return null;const s="string"==typeof e?.default_floor?e.default_floor:"";return!s||o.some(e=>e.value===s)?null:s}(this._config,this._spaces,this._spacesAuthoritative),d=e=>c`<ha-form
.hass=${this.hass}
.data=${this._formData}
.schema=${e}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-DdUxnMX-.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-DAhkK64J.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";import{l as t,ar as s,A as e,t as o,c as a,bV as l,bW as i,bX as h,bY as c,ao as n,bZ as p,c2 as r,b_ as _,b$ as d,c0 as u,c1 as g,cX as m,an as b,ap as $,cY as v}from"./houseplan-card-DzlQl4_k.js";import{i as f,c as y,e as w,r as D,a as S,b as k,s as C,t as M}from"./backdrop-pick-CJ3kZCi2.js";const F=1e3,x=t=>{if(!t.trim())return null;const s=Number(t.replace(",","."));return Number.isFinite(s)?s:null},T="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";class I{constructor(t){this.host=t,this._toggleServerPlans=async()=>{const t=this.host._spaceDialog;if(t)if(t.pickSaved)this.host._spaceDialog={...t,pickSaved:!1};else{this.host._spaceDialog={...t,pickSaved:!0,savedBusy:!0};try{const t=await this.host.hass.callWS({type:"houseplan/plans/list"}),s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:t?.plans||[],savedBusy:!1})}catch(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,saved:[],savedBusy:!1}),this.host._showToast(this.host._t("toast.plans_list_failed",{err:this.host._errText(t)}))}}}}_help(l){const i=`${l}.aria`,h=t(this.host.hass,this.host._config?.language);return s(h,l)&&s(h,i)?a`<hp-help data-help-key=${l}
.text=${o(h,l)} .ariaLabel=${o(h,i)}></hp-help>`:e}_openSpaceDialog(t,s){if(!this.host._serverStorage||!this.host._serverCfg)return void this.host._showToast(this.host._t("toast.integration_missing"));if("edit"===t){const e=this.host._serverCfg.spaces.find(t=>t.id===s);if(!e)return;const o=l(e),a=e.settings?.custom_fill&&"object"==typeof e.settings.custom_fill?i(e.settings.custom_fill):null,r="none"===o.fill?{...a||h,a:0}:a;return void(this.host._spaceDialog={mode:t,spaceId:s,title:e.title,planUrl:e.plan_url||null,planFile:null,source:e.plan_url?"file":"draw",showBorders:o.showBorders,showNames:o.showNames,zeroWallStyle:p(e),displayTouched:!0,hideDecor:o.hideDecor,hideOpenings:o.hideOpenings,roomColor:o.color,roomOpacity:o.opacity,fillMode:"none"===o.fill?"custom":o.fill,customFill:r,glowEnabled:o.glow,bgColor:o.bgColor,bgMode:"static"===e.settings?.bg_mode||"daynight"===e.settings?.bg_mode?e.settings.bg_mode:null,northDeg:n({},e.settings),sunRays:"boolean"==typeof e.settings?.sun_rays?e.settings.sun_rays:null,tempMin:o.tempMin,tempMax:o.tempMax,showLqi:o.showLqi??this.host._config?.show_signal??!0,cardFontScale:o.cardFontScale,labelTemp:o.labelTemp,labelHum:o.labelHum,labelLqi:o.labelLqi,labelLight:o.labelLight,cellCm:Number(e.cell_cm)>0?Number(e.cell_cm):5,cellCmInput:c(Number(e.cell_cm)>0?Number(e.cell_cm):5,this.host._imperial),cellCmTouched:!1,busy:!1})}const e=r(this.host._imperial);this.host._spaceDialog={mode:t,title:"",planUrl:null,planFile:null,...f(),hideDecor:!1,hideOpenings:!1,zeroWallStyle:"dashed",roomColor:g,roomOpacity:u,fillMode:"custom",customFill:{...h,a:0},glowEnabled:!0,bgColor:null,bgMode:"daynight",northDeg:null,sunRays:null,tempMin:d,tempMax:_,showLqi:this.host._config?.show_signal??!0,cardFontScale:1,labelTemp:!1,labelHum:!1,labelLqi:!1,labelLight:!1,cellCm:e,cellCmInput:c(e,this.host._imperial),cellCmTouched:!1,busy:!1}}async _pickPlanFile(t){const s=t.target,e=s.files?.[0];if(!e||!this.host._spaceDialog)return;s.value="";const o=await y(e);if("reject"===o.kind)return void this.host._showToast(this.host._t("toast.plan_formats"));if("guard"===o.kind)return void(this.host._backdropGuard=o.state);const a=await w(e,o.ext,e.name);this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:a})}_renderBackdropGuard(){return D(this.host,t=>{this.host._spaceDialog&&(this.host._spaceDialog={...this.host._spaceDialog,planFile:t})},()=>{this.host._backdropGuard=null},this.host.hass)??e}_useServerPlan(t){const s=this.host._spaceDialog;s&&(this.host._spaceDialog={...s,planUrl:t,planFile:null,pickSaved:!1,savedAspect:void 0},this.host._aspectJob=this._readPlanAspect(t))}async _readPlanAspect(t){for(let s=0;s<40;s++){const s=this.host._display(t);if(s){const e=await new Promise(t=>{const e=new Image;e.onload=()=>t(e.naturalWidth&&e.naturalHeight?e.naturalWidth/e.naturalHeight:0),e.onerror=()=>t(0),e.src=s}),o=this.host._spaceDialog;return o&&o.planUrl===t&&Number.isFinite(e)&&e>0?(this.host._spaceDialog={...o,savedAspect:e},e):0}if(await new Promise(t=>setTimeout(t,150)),this.host._spaceDialog?.planUrl!==t)return 0}return 0}async _deleteServerPlan(t){if(confirm(this.host._t("confirm.delete_plan",{name:t})))try{await this.host.hass.callWS({type:"houseplan/plans/delete",name:t});const s=this.host._spaceDialog;s?.saved&&(this.host._spaceDialog={...s,saved:s.saved.filter(s=>s.name!==t)})}catch(t){this.host._showToast(this.host._t("toast.plan_delete_failed",{err:this.host._errText(t)}))}}_renderServerPlans(t){if(t.savedBusy)return a`<div class="savedplans muted">${this.host._t("space.loading")}</div>`;const s=t.saved||[];if(!s.length)return a`<div class="savedplans muted">${this.host._t("space.no_saved")}</div>`;return a`<div class="savedplans">
${s.map(s=>{return a`
<div class="savedplan ${s.url===t.planUrl?"cur":""}">
+1 -1
View File
@@ -1 +1 @@
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="a75fd51476e6981375898466243f1f67acd5af627334883a490aeed45b89813f";try{await import("./houseplan-assets/houseplan-card-DdUxnMX-.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="099688ecae2a6f015325a6ffa40c7112bd2726309f7760aec7d6b2534ad2f347";try{await import("./houseplan-assets/houseplan-card-DzlQl4_k.js")}catch(e){if(!customElements.get("houseplan-card")){const l=String(navigator.language||"en").toLowerCase();const m=l.startsWith("ru")?"House Plan обновился — перезагрузите страницу (Ctrl+F5).":l.startsWith("de")?"House Plan wurde aktualisiert — bitte laden Sie die Seite neu (Strg+F5).":l.startsWith("fr")?"House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5).":"House Plan was updated — please reload the page (Ctrl+F5).";customElements.define("houseplan-card",class extends HTMLElement{setConfig(){}getCardSize(){return 1}connectedCallback(){this.style.cssText="display:block;box-sizing:border-box;padding:16px;border:1px solid var(--divider-color,#e0e0e0);border-radius:var(--ha-card-border-radius,12px);background:var(--card-background-color,#fff);color:var(--primary-text-color,#212121);font:14px/1.4 var(--paper-font-body1_-_font-family,sans-serif)";this.textContent=m}})}console.error("[houseplan] stale entry: the main chunk is unavailable",e)}
+12
View File
@@ -1471,6 +1471,18 @@ retarget. Reduced motion commits the exact target atomically. Visibility loss,
space change, recovery and disconnect cancel or settle this same owner rather
than leaving CSS timers or WAAPI animations behind.
## Camera-only transition ownership (#82)
`src/viewport-transition.ts` owns a separate one-token/one-RAF controller for
discrete zoom commands inside an already settled mode. It shares #101's easing
primitive but only interpolates reactive `{zoom, viewBox}`: no chrome,
background, layer opacity or CSS transform. Wheel retargets from the camera
actually presented while accumulating from the pending target; pointer/pinch,
mode, space, projection, resize, structural adoption, visibility and teardown
are explicit ownership boundaries. The component remains the sole writer of
camera state and persists one exact target only after settle. The controller
lives in the core View bundle and does not import the lazy editor runtime.
## Settings tiers (owner's principle, 2026-07-26)
+12
View File
@@ -251,6 +251,18 @@ produce a zero-sized SVG `viewBox`.
* **Zoom in** — unchanged, `ZOOM_MAX = 8`.
* **Zoom out** — `MIN_ZOOM = 1/3`: you can see three times the content
frame and no further. Empty space beyond that is not information.
* **Discrete camera motion** (#82) — wheel, `−`/`+`, Fit all, the home arrow
and kiosk double-tap interpolate the existing exact camera target for
160–220 ms with `cubic-bezier(0.2, 0.7, 0.2, 1)`. Zoom is logarithmic and
the world centre is linear; the final frame is the same clamped `viewBox`
the immediate path used before #82. Rapid wheel input retargets one RAF from
the camera actually on screen, accumulates from its pending target and keeps
the current pointer anchor. It never queues transitions or uses a CSS scale.
Pinch and pan remain direct 1:1 gestures. Pointerdown freezes the presented
camera; mode, space, projection, resize, structural adoption, hidden state
and disconnect cancel or settle it before taking ownership. Reduced motion
always commits the exact target immediately. View persists only the settled
target once; editor camera remains session-only.
* **Pan** — available at **every zoom**, in view mode and in every
editor, and bounded by the content frame inflated by
`PAN_SLACK = 1.0` of `max(view, frame)` on each side. You can walk
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Wheel, zoom buttons, **Fit all**, the home arrow and kiosk double-tap now
move the plan through a short smooth camera transition. Pinch and pan remain
direct, and reduced-motion systems still update instantly
([#82](https://github.com/Matysh/houseplan-card/issues/82)).
- The Device editor now has its own Undo/Redo history for marker positions.
One completed drag is one step; cancelled or failed moves add nothing, and
the 50-step session history is available from persistent toolbar buttons or
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Колесо, кнопки масштаба, «Показать всё», стрелка возврата и двойной тап в
киоске теперь плавно перемещают камеру за короткую анимацию. Щипок и
панорамирование остаются непосредственными, а при системном уменьшении
движения масштаб меняется мгновенно
([#82](https://github.com/Matysh/houseplan-card/issues/82)).
- В редакторе устройств появилась отдельная история отмены и повтора позиций
маркеров. Одно завершённое перетаскивание — один шаг; отменённые и неудачные
перемещения в историю не попадают. До 50 шагов текущей сессии доступны через
+3 -1
View File
@@ -45,7 +45,9 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
while reload/return from another HA route always starts in View (#93).
**Kiosk mode** (v1.41.0): `kiosk: true` — no
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
carousel, per-screen size multipliers in localStorage.
carousel, per-screen size multipliers in localStorage. Discrete wheel,
button, fit/home and kiosk-reset camera commands use one short retargetable
transition; direct pan/pinch and reduced motion remain immediate (#82).
- **Independent Glow overlay** (#55/#19, refined locally by #61/#65–#67): dark-room
base is used only when the effective fill resolver returns `null`, including
dynamic modes without usable data; resolved LQI/light/temp/custom colors keep
+5
View File
@@ -1940,6 +1940,11 @@ separately promised workflows:
## Zoom / pan / labels
- [ ] Wheel zoom at cursor; +/− buttons; fit button resets; badge shows %
- [ ] Discrete zoom has an intermediate frame and exact legacy target; rapid
wheel retarget/reversal keeps its live pointer anchor and one final
persistence write; pointerdown/pinch, mode/space/projection/resize,
hidden and reduced motion leave no stale camera animation
[auto: smoke_smooth_zoom]
- [ ] Pinch zoom + two-finger pan on touch; one-finger pan when zoomed
- [ ] Zoom level persists per space (localStorage), restored on reload
- [ ] Window resize / sidebar collapse refits without distortion
+6
View File
@@ -231,6 +231,12 @@ objects; each space keeps its local View viewport.
At zoom above 1:1 a horizontal kiosk gesture pans instead of changing space.
Any manual kiosk operation pauses auto-cycle for 60 seconds.
Wheel, `−`/`+`, **Fit all**, the return arrow and kiosk double-tap use a short
smooth camera transition. Rapid wheel input changes the current destination
instead of building a queue. Pinch and pan stay directly under the fingers.
With the operating system's reduced-motion preference enabled, every zoom
command is immediate.
### Cancel and undo
- `Esc` finishes an active Walls chain without deleting its accepted segments.
+5
View File
@@ -262,6 +262,11 @@ desktop: для точного рисования, Resize, модификато
Особенности:
- колесо, кнопки `−`/`+`, «Показать всё», стрелка возврата и двойной тап в
киоске используют короткий плавный переход камеры; частые события колеса
меняют текущую цель, а не выстраиваются в очередь;
- щипок и панорамирование по-прежнему следуют за пальцами без задержки; при
включённом в системе уменьшении движения любой масштаб меняется мгновенно;
- панорамирование работает при любом масштабе;
- масштаб режима просмотра запоминается отдельно для каждого пространства на текущем клиенте;
- масштаб и панорама внутри редактора — рабочие и не заменяют сохранённый вид просмотра;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

+16 -16
View File
@@ -3,8 +3,8 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"captureScriptSha256": "fe76c8e2e27501e9a377d7aa15dd6cba6a018d55c07deb602dbee097f0a0a5ba",
"sourceFingerprint": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"captureScriptSha256": "9229c467130e1f3d3abd042e2dcabaca9d4d5a918d0d77eadc814848df597ba6",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
"view-desktop": {
@@ -15,8 +15,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"imageSha256": "f8c9541c9d7fde462d0efba63dff70944dc03df76ce988eecaf2b269e2863a96"
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "9119ab87502038f787529f621c39e1e0d01f3bc3b0289051c3791a1886e97a6b"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "21c6fe7d79ff5c878ade8e98a64cc90dfd7bcc66338c864fb5a0bd411471313d"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "fb32ea4a785fedb0e99b0bca6dbcf19022b32ced763ed875a57eb453be550bf5"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "75e0bc3dedff68e29be46531df0cc205f69988e86229580cc021017932049b34"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "5fc29f4a71572ed43221131cd6e5f9a3da19fcc9bf63c879b12664bee98e292a"
},
"device-editor": {
@@ -70,8 +70,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"imageSha256": "f9a8c003ed0fbd6ba7d26cd5642207beabb78a3b62b972e35505ac95830a9a95"
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "49e14ee30284b44bc914a67c368abef6d8ac26ef320079e985f1edd36c7985d7"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "7185c75cf0e3629c7bc880381d1eab1d1f67a2484ca3d17e86ad53e0ed6387c8"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "3d0d088f89da53a73efd13ef5bb5403f407011ab918e88671974459b5967ca25"
},
"room-card": {
@@ -103,8 +103,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"imageSha256": "f1bc10b478f7afc68e70cb4c37148f680b0bbd41d50a7d63a21067fc952ff134"
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "93d88ed84e2e3f9071e94e4b74cc3f3a86c7f898a76d1fb26bcc499e7dc4f538"
},
"device-info": {
"file": "09-device-info.png",
@@ -114,8 +114,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "bb60991ea760ae67b18d25ba190e232420ada3c20b5bb45abc9c2ccc85ff86b2",
"imageSha256": "2634cd7ead5720ce46ea4d6fa8f31006b740c520a73ddc97afe9a6d9d3b49778"
"sourceSha256": "d27cbb20bea10b3d0a75f904be32de014d651e0c4e98a869e24c78fc0021a235",
"imageSha256": "4d2257e49b28d3be517ed382269569b0e3bc18cec9d84d56b5f407c505a9b398"
}
}
}
+103
View File
@@ -0,0 +1,103 @@
# CODE-REVIEW #82 · r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/82 «Плавное масштабирование zoom/fit/reset»
- **ТЗ:** `docs/specs/082-smooth-zoom.md` (зелёное ревью `SPEC-REVIEW-82-r1`, 2026-08-30)
- **Заход:** r1 (это первый код-ревью этой задачи; предыдущих циклов нет)
- **SHA материала:** `779382afec951ce8f84a4d62a8eb700cf4c1bc4a` (`git rev-parse HEAD` перед подведением итогов)
- **Диапазон:** `origin/dev...HEAD`, 12 коммитов (`895b120e`…`779382af`)
- **Причина полного разбора (не по дельте):** это первый код-ревью цикл этой задачи — §2.10 применяется со второго раунда. Отдельно ветка была ребейзнута конвейером до ревью (2 коммита dev: `9653ba17 → 779382af`), что по §7.2 само по себе требует полного разбора, а не только разбора добавленных commit'ов.
## Скоуп диффа
`git diff origin/dev...HEAD --stat`: 55 файлов, из них по классам:
- **A (продукт):** `src/houseplan-card.ts` (+178/-99), `src/mode-transition.ts` (переименование `ease` → экспортируемый `easeTransitionProgress`), новый `src/viewport-transition.ts` (212 строк, чистый camera-only контроллер).
- **B (гейты/тесты):** новый `test/viewport-transition.test.mjs`, новый `demo/smoke_smooth_zoom.mjs`, правки в `demo/smoke_infinite_canvas.mjs`, `demo/smoke_canvas_frame.mjs`, `demo/smoke_kiosk.mjs`, `demo/smoke_kiosk_pan_lock.mjs` (ожидание settle камеры), `demo/golden/harness.mjs`, `demo/docs/capture.mjs` (то же самое для golden/docs-захвата), `tsconfig.test.json`.
- **C (документация):** `docs/ARCHITECTURE.md`, `docs/CANVAS.md`, `docs/CHANGELOG.md`/`.ru.md`, `docs/STATUS.md`, `docs/TESTING.md`, `docs/USER-GUIDE.md`/`.ru.md`, `docs/specs/README.md`, `docs/specs/082-smooth-zoom.md`, `docs/reviews/SPEC-REVIEW-82-r1.md`.
- **D (генерируемое):** `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (8 файлов + `baselines-index.json`), `docs/images/*.png` + `screenshots.json`.
Продуктовый код сосредоточен в одном новом модуле плюс точечных правках `houseplan-card.ts` — контракт §8 «Единый camera-only controller» выдержан буквально: `CameraTransitionController` не знает о Lit/localStorage/режимах; `houseplan-card.ts` остаётся единственным писателем `_zoom`/`_view`.
## Как проверялось
Зелёного Validate на `779382af` на момент начала ревью не было — прогнал гейты сам.
| Гейт | Команда | Результат |
| --- | --- | --- |
| Typecheck | `npx tsc --noEmit` | чисто, без ошибок |
| Unit-тесты | `npm test` | 1642 всего: 1641 pass, 1 skip, 0 fail |
| Build + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны (третья копия `demo/srv/assets/**` не коммитится, #255 — синхронизирована `npm run bundle:sync` для локального прогона смоков) |
| `no-new-any` | `node scripts/no-new-any.mjs --base origin/dev --head HEAD` | 368 добавленных строк в 3 файлах, новых `any` нет |
| `check-docs` | `node scripts/check-docs.mjs` | пройден (7 файлов, 10 внешних ссылок); `sourceFingerprint`/`captureScriptSha256` в `docs/images/screenshots.json` пересчитаны, `imageSha256` изменился только там, где реально поменялась картинка |
| `smoke-select` | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 49 прямых совпадений, 33 слабых связи (полный вывод приложен ниже) |
| Browser smoke (по необходимости, см. ниже) | `node demo/smoke_*.mjs` (15 файлов) | все зелёные |
| `single-source-numbers` | `node --test test/single-source-numbers.test.mjs` | 3/3 pass |
| `golden:verify` | `npm run golden:verify` | все сценарии `passed`, 0 расхождений, exit 0 |
| `pytest tests_backend` | — | не прогонял: диф не касается `custom_components/**/*.py` |
| `model-invariants` | — | не прогонял: диф не трогает геометрию комнат/стен/`layout`/`marker.space`/`open_spans` — только камеру (viewport presentation) |
| Performance-профиль / screencast (AC13) | — | не прогонял: §21 ТЗ и §18 план тестов прямо относят этот гейт к предрелизному Full Performance/screencast прогону, не к код-ревью; тот же прецедент уже принят для #73 и зафиксирован как Low в `SPEC-REVIEW-82-r1` |
### Разбор вывода `smoke-select.mjs`
Изменено 3 файла в `src/**`, 57 символов проекта на изменённых строках → порог «широкого» символа 41, матрица 208 смоков.
**Прогнал (15 из 49 прямых совпадений + релевантные регрессии из плана автотестов ТЗ §18):**
- `demo/smoke_smooth_zoom.mjs` — новый, основной для AC1–AC12, AC14.
- `demo/smoke_zoom_out.mjs`, `demo/smoke_infinite_canvas.mjs`, `demo/smoke_canvas_frame.mjs` — прямое совпадение **и** правлены в этом же диффе (ожидание settle камеры) → обязательны.
- `demo/smoke_kiosk.mjs`, `demo/smoke_kiosk_pan_lock.mjs` — прямое совпадение, правлены в диффе, и это «kiosk pan-lock», прямо названный в плане тестов ТЗ §18.
- `demo/smoke_visual_continuity.mjs`, `demo/smoke_warm_remount.mjs` — «#73 visual continuity / warm remount» из плана §18 (в выводе инструмента отсутствуют — связь не по имени символа, а по контракту; прогнал по требованию ТЗ, а не по подсказке инструмента).
- `demo/smoke_mode_transition.mjs` — «#101 View/editor mode transition» из плана §18 (слабая связь по инструменту, обязательна по ТЗ).
- `demo/smoke_isometric_contract.mjs` — «flat/isometric projection switch» из плана §18 (слабая связь по инструменту).
- `demo/smoke_glow.mjs`, `demo/smoke_glow_geometry_resilience.mjs` — «heavy Glow camera path» из плана §18 (отсутствуют в обоих списках инструмента — НЕОПРЕДЕЛЁННОСТЬ по символам, связь по контракту AC11).
- `demo/smoke_furniture.mjs`, `demo/smoke_opening_preview.mjs` — «opening/furniture preview interruption» из плана §18 (прямые совпадения).
- `demo/smoke_editor_gestures.mjs` — прямое совпадение (`_zoomAt`), проверяет pinch/pan в editor-контексте (AC2).
**Не прогонял** оставшиеся ~36 прямых совпадений и все 33 слабые связи: это преимущественно смоки геометрии стен/проёмов/decor, где единственная точка касания — общий `_baseVb`/`_viewOr`/`_modeTransitionBusy`, не задетые этим диффом по существу (диф не меняет их логику, только оборачивает существующий `_zoomAt`/`_resetZoom` в контроллер). Полный список — предрелизная обязанность (§8), не гейт ревью при таком объёме диффа.
## Находки
Блокирующих находок нет.
### Low (не блокируют, зафиксированы с решением ревьюера)
1. **Коммит `55cf36d6 fix: preserve structural adoption guard` — не функциональный фикс.** Диф этого коммита в `src/houseplan-card.ts` — чистая перестановка порядка двух независимых операторов внутри одного и того же `if (configChanged)` (`_cancelCameraTransition(false)` вызывается после `_geometryHistory.clear()`/`_pendingPhysicalWrites.clear()` вместо «до»). `_cancelCameraTransition` не читает и не пишет geometry history, поэтому порядок не влияет на наблюдаемое поведение — обе версии эквивалентны семантически. Коммит промаркирован `fix:`, хотя по факту ничего не чинит; наблюдение чисто по формулировке сообщения коммита, к продукту претензий нет. Решение: не возвращать, отметить и снять.
2. **AC13 (performance/screencast для тяжёлых Glow/flat/isometric fixtures) закрыт только предрелizным гейтом**, не автотестом в этом диффе — уже принято как Low в `SPEC-REVIEW-82-r1` (прецедент #73), подтверждаю то же решение на уровне кода: `src/viewport-transition.ts` не делает ничего, что могло бы задеть Glow/geometry rebuild (чистая интерполяция `{zoom, viewBox}`, никаких side-effects на кадр), и `glowAndStructuralFrameStayStable` в `smoke_smooth_zoom.mjs` подтверждает отсутствие rebuild на обычном фикстуре. Решение: не блокирует, предрелизный Full Performance гейт остаётся обязательным перед бетой.
## Проверено и корректно (по каждому AC)
| AC | Доказательство | Как проверено |
| --- | --- | --- |
| AC1 | Unit (`interpolateCameraState` start/mid/end) + `smoke_smooth_zoom`: `buttonHasIntermediateFrame`, `buttonSettlesAtExactTarget`, `wheelHasIntermediateFrames`, `fitHomeAndDoubleTapAnimate`, `farFitUsesTheSameTransition` | исполнено, все true |
| AC2 | `smoke_smooth_zoom`: `pinchIsDirectWithoutPostAnimation`; `smoke_editor_gestures`: `panWorksAndDoesNotDraw`, `pinchZoomsInPlanEditor` | исполнено |
| AC3 | Единая точка записи `_zoom`/`_view` в `_applyCameraTransitionFrame`/`_settleCameraTransition`, которую читают все слои рендера (нет отдельного copy камеры для SVG/HTML) | проверено чтением: нет второго источника координат в диффе |
| AC4 | Unit (`retarget starts from presented state and cancels the obsolete RAF`) + `smoke_smooth_zoom`: `wheelRetargetsRunningTween`, `wheelReversalKeepsAnchor` (≤0.5 px), `wheelStreamPersistsOnce` | исполнено |
| AC5 | Unit (`cancel policies...`) + `smoke_smooth_zoom`: `exactLimitsAndFitAreNoops` (RAF=0, saves=0 на fit/min/max) | исполнено |
| AC6 | pointerdown/hidden/projection — `smoke_smooth_zoom`: `pointerdownFreezesPresentedFrame`, `hiddenCommitsTargetOnce`, `projectionCancelsAndIsoSettlesExactly`. Mode/space/resize/adoption — код читает `_cancelCameraTransition(false)` в `_cancelModeTransition`, `_commitSpace`, `_refitView`, `_adoptStructuralResponses` (оба `configChanged`/`layoutChanged` пути) | смешанно: исполнено (pointer/hidden/projection) + проверено чтением (mode/space/resize/adoption) |
| AC7 | `smoke_warm_remount`, `smoke_visual_continuity` — все проверки зелёные без затрагивания камеры-контроллера (atomic paths используют `_applyView`/прямые пути, не `CameraTransitionController`) | исполнено |
| AC8 | Unit (duration=0 → `canAnimate=false` → немедленный settle) + `smoke_smooth_zoom`: `reducedMotionIsImmediate` (до и во время команды) | исполнено |
| AC9 | `_saveZoom()` вызывается ровно один раз на settle (не на кадр); ранний `if (this._mode !== 'view') return` в `_saveZoom` — не тронут, продолжает не писать editor zoom. `smoke_smooth_zoom`: `buttonPersistsOnce`, `wheelStreamPersistsOnce` (оба `=== 1`) | исполнено + проверено чтением для editor-ветки |
| AC10 | `smoke_isometric_contract` зелёный без изменений; `smoke_smooth_zoom`: `isoFinal` сравнивается с `_cameraTargetAt(...).target` — точное совпадение | исполнено |
| AC11 | `smoke_smooth_zoom`: `glowAndStructuralFrameStayStable` (глубокое сравнение JSON до/после zoom-команды); `smoke_glow`, `smoke_glow_geometry_resilience` зелёные | исполнено |
| AC12 | Unit `cancel policies...` (RAF=0 после `cancel(false)`, идентично `dispose()`); `disconnectedCallback` вызывает `_cameraTransition.dispose()` | проверено чтением + эквивалентный unit-путь |
| AC13 | См. Low-находку №2 — предрелизный гейт, не код-ревью | не прогонялось (осознанно, см. находки) |
| AC14 | `smoke_smooth_zoom`: `coldViewDoesNotLoadEditorRuntime` (проверка `performance.getEntriesByType('resource')` на отсутствие `houseplan-editor-runtime-*`) | исполнено |
Дополнительно по §8 ревью-инструкции: **«одно число — один источник»** — zoom badge (`Math.round(this._zoom * 100)}%`) остаётся единственным местом чтения `_zoom`; других мест, где масштаб пересчитывается отдельно для отображения, диф не добавил. `test/single-source-numbers.test.mjs` зелёный.
Терминология сверена с `docs/USER-GUIDE.md`/`.ru.md` и `src/i18n/en.json`/`ru.json`: «Fit all» / «Вписать всё» (`title.zoom_fit`), нет расхождений.
Release-артефакты (§21 ТЗ) на месте и в одном коммите с поведением: `CHANGELOG.md`/`.ru.md` (в `c4b8022c`, том же, что и `User-Visible: yes`), `USER-GUIDE.md`/`.ru.md`, `CANVAS.md`, `ARCHITECTURE.md`, `TESTING.md`, `STATUS.md`. Golden-коммит `779382af` несёт обязательные `Release:`/`Baseline-Reviewed:` трейлеры и внятную причину (8 изменений унаследованы от `dev`, не связаны с фичей, 139 сценариев не тронуты).
## Чего не проверял
- Полный набор из 208 browser-смоков — не по теме, не по AC; прогнал только «прямые совпадения + названные в ТЗ» (см. таблицу smoke-select выше). Оставшиеся ~36 прямых совпадений — geometry/decor-смоки, не проверял.
- `pytest tests_backend` — диф не касается Python.
- `model-invariants` — диф не касается геометрии/ссылок/`layout`.
- Performance-профиль / screencast для AC13 — сознательно оставлено предрелизному гейту (см. Low №2).
- Реальный ручной прогон в браузере (Home Assistant инстанс) — не выполнялся, ревью проходит без ручного тестирования по процессу; вопрос «работает ли» закрыт автотестами + browser-smoke выше.
- Не проверял содержимое `Baseline-Reviewed` CI-прогона (`33319326145`) построчно — доверился описанию коммита и совпадению `139 сценариев не изменились` с локальным `golden:verify` (все `passed`).
## Итог
Реализация точно следует контракту ТЗ §8–§13: отдельный camera-only controller, один RAF/token owner, retarget от представленного кадра с накоплением от pending target, явные границы отмены/владения, persistence только на settle с сохранением существующего editor-guard. Все AC либо доказаны исполненным автотестом, либо разобраны по коду с точным указанием строк. Единственные находки — Low, не входящие в скоуп блокировки. Вердикт: зелёный.
+195
View File
@@ -0,0 +1,195 @@
# CODE-REVIEW-82-r2
- **Issue:** #82 «Плавное масштабирование плана: анимация zoom/fit/reset»
- **Этап:** code (PROCESS.md §2.7)
- **Заход:** r2 · блокирующих циклов израсходовано 0 из 4 (r1 был зелёным, бюджет не тратил, #227)
- **Материал:** `origin/dev...HEAD`, HEAD = `cc73252aa73007db3bd636b02eec3dd8ece871b2`
- **origin/dev на момент ревью:** `e39cd3dfa1453be140ab809cd4344f9474bd0b2c`
- **Диапазон:** 13 коммитов, `8a18e812`…`cc73252a`
- **Вердикт:** зелёный · High: 0 · Medium: 0
## Почему разбор полный, а не по дельте
После зелёного r1 (вердикт на SHA `779382af`, комментарий 2026-08-30T15:51:14Z)
ветка была ребейзнута на ушедший вперёд `dev` (`e39cd3df`), а после этого
довешено ещё 6 коммитов, которых r1 не видел (`a945ca47`…`b5324ce4`, см. ниже).
Ребейз на ушедший вперёд dev — один из явных случаев, для которых инструкция
требует полный разбор, а не разбор по дельте (§7.2: после ребейза это другой
код). Поэтому весь диапазон `origin/dev...HEAD` перечитан и перепроверен
заново, включая часть, которую r1 уже одобрил, а не только новые 6 коммитов.
Отдельное наблюдение по процессу (не находка к коду, Low): комментарий
владельца «Финальный rebase… Единственный конфликт был в
docs/images/screenshots.json… исходный код и поведение камеры не
конфликтовали» описывает состояние ДО того, как в ветку попали ещё 6 коммитов
(`a945ca47`…`b5324ce4`), один из которых (`64f54fd4`) меняет реальное поведение
`src/houseplan-card.ts` (см. AC1/AC5 ниже), а не только тесты и скриншоты.
Ни один из этих 6 коммитов не описан в отдельном комментарии issue. Это не
блокирует ревью — полный разбор ниже покрывает эти коммиты по существу — но
статус-комментарии в этом раунде не отражали фактический diff на момент
возврата задачи в S7.
## Что проверено и как
### Дешёвые гейты
Validate на точном HEAD `cc73252a` зелёный
(https://github.com/Matysh/houseplan-card/actions/runs/33320914612), поэтому
`tsc --noEmit`, `npm test` (1642, см. предыдущий зелёный прогон) и
`npm run build` со сверкой копий бандла не перегонялись как отдельная
проверка. Тем не менее `npm run bundle:sync` (= `tsc --noEmit && rollup -c` +
раскладка) выполнен локально по ходу подготовки к golden — прошёл чисто,
`dist`/`custom_components/.../frontend`/`demo/srv/assets` идентичны после
сборки, `git status` остался чистым (значит закоммиченные бандлы уже
соответствуют исходникам этого HEAD).
`node scripts/check-docs.mjs` — выполнен (diff трогает `src/**`):
`Documentation checks passed (7 files, 10 external links)`.
### Golden (не покрывается перечнем «дешёвых» гейтов из шапки этого раунда)
Diff меняет рендер (camera-контроллер, 8 «унаследованных из dev» golden-baseline
и 4 doc-скриншота, включая правку `screenshots.json`). Запущен
`npm run golden:verify` — **147/147 passed**, включая все 8 файлов из коммита
«test: accept inherited dev golden updates» (`device-dialog-desktop-de`,
`device-dialog-mobile-ru`, `toggle-entity-dialog-mobile-ru`,
`device-ripple-color-popover-mobile-ru`, `tray-medium-group-en` и т.д. — все
`passed` в выводе). Это подтверждает утверждение коммита не как заявление
автора, а как воспроизведённый прогон: текущий код рендерит ровно то, что
принято в качестве эталона, независимо от того, что сами PNG побайтово
отличаются от версии в `origin/dev` (шумовая перекодировка/иной прогон
захвата — не дефект).
### Browser smoke — выбор по диффу
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`: 49 прямых
совпадений, 33 слабые связи (общий символ вроде `_modeTransitionBusy`,
`stopPropagation`). Прогнаны:
- все смок-файлы, которые сам diff **редактирует** (значит, r1 их в этом виде
не видел): `smoke_canvas_frame`, `smoke_infinite_canvas`, `smoke_kiosk`,
`smoke_kiosk_pan_lock` — все правки только добавляют `await` на
устаканивание камеры перед проверкой существующих инвариантов, поведение не
меняют;
- главный доказатель AC — `smoke_smooth_zoom` (250 строк, покрывает
AC1–AC12, AC14 согласно ТЗ §18);
- обязательный минимум регрессий из ТЗ §18 и уже проверенный r1 набор:
`smoke_zoom_out`, `smoke_editor_gestures`, `smoke_furniture`,
`smoke_opening_preview`, `smoke_visual_continuity` (#73),
`smoke_warm_remount`, `smoke_mode_transition` (#101),
`smoke_isometric_contract`, `smoke_glow`, `smoke_glow_geometry_resilience`.
Все 15 — **зелёные** (полный вывод каждого — все ключи `true`/`OK`).
Остальные 34 «прямых» совпадения из смок-select не прогнаны: они привязаны к
`_baseVb`/`_viewOr`/`_cursorPt`/`_modeTransitionBusy` — символам, которые
внутри строки почти не меняли поведение (только сигнатуры точек входа и
уборка opening-курсора при старте команды камеры, без изменения самой логики
черчения/декора/wall-thickness). Слабые связи (33) не прогонялись — общее имя
без прямого попадания в изменённый символ.
### Что не прогонялось и почему
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`
(только сгенерированный JS-бандл внутри `custom_components/.../frontend`).
- `npm run invariants` — diff не трогает геометрию (нет правок рёбер комнат,
записей толщины, `layout`, `marker.space`, `open_spans`); камера работает
только с `viewBox`/`zoom`.
- Performance-профиль/screencast для AC13 — не прогонялся. ТЗ §21 и матрица
AC13 сами относят это к предрелизному Full Performance гейту, не к
автотесту в цикле ревью; то же решение уже дважды принято (r1 spec-review и
r1 code-review). Косвенно AC13-риск (Glow/geometry rebuild на кадре)
проверен `glowAndStructuralFrameStayStable` в `smoke_smooth_zoom` и
`smoke_glow_geometry_resilience` — оба зелёные.
- `test/single-source-numbers.test.mjs` — механически входит в `npm test`
(уже зелёный по Validate). Смысловая проверка сделана вручную: zoom badge
(`src/houseplan-card.ts:11517`, `Math.round(this._zoom * 100)}%`) — единственное
место, где процент зума выводится пользователю; он читает `this._zoom`
напрямую, то же поле, которое `_applyCameraTransitionFrame` обновляет на
каждом кадре. Второго источника числа не найдено.
## AC1–AC14: доказательства
| AC | Статус | Доказательство |
| --- | --- | --- |
| AC1 | Выполнен | `smoke_smooth_zoom`: `buttonHasIntermediateFrame`, `fitHomeAndDoubleTapAnimate`, `farFitUsesTheSameTransition` — все `true`; код `_stepZoom`/`_resetZoom`/`_fitAll`/`_fitFar` в `src/houseplan-card.ts:6270-6345` строят target существующей `fitView`/`_clampView` и передают его в `CameraTransitionController` |
| AC2 | Выполнен | `smoke_smooth_zoom.pinchIsDirectWithoutPostAnimation`, `smoke_editor_gestures` (pinch/pan в редакторе) — зелёные; `_zoomAt()` (immediate-путь) не создаёт transition |
| AC3 | Выполнен | Прочитано кодом: единственные reactive `_zoom`/`_view` пишутся только в `_applyCameraTransitionFrame`/`_settleCameraTransition`/`_zoomAt`, все SVG/HTML/hit-слои читают их через общий рендер-путь; `smoke_smooth_zoom.pointerdownFreezesPresentedFrame` подтверждает, что pointerdown видит реально представленный кадр, а не устаревший target |
| AC4 | Выполнен | `smoke_smooth_zoom.wheelRetargetsRunningTween` (разные token, `reverseFrom≈presentedBeforeReverse`), `wheelReversalKeepsAnchor` (≤0.5px) |
| AC5 | Выполнен | `smoke_smooth_zoom.exactLimitsAndFitAreNoops` (`active=false`, `saves=0` на fit/max/min); код `_startCameraTransition` (houseplan-card.ts:1207-1223) возвращает `false` без RAF/`_saveZoom`, если target совпадает с текущим или с уже бегущим target |
| AC6 | Выполнен | `pointerdownFreezesPresentedFrame`, `projectionCancelsAndIsoSettlesExactly`, `hiddenCommitsTargetOnce` — все `true`; код: `_cancelCameraTransition` вызван в `_stagePointerDown`, `_commitSpace`, `_setProjection`-пути (через `_cancelModeTransition`/adoption), `_refitView`, `_pageVisibility('hidden')`, config/layout adoption (`b3abfc67`) |
| AC7 | Выполнен | `smoke_warm_remount`, `smoke_visual_continuity` (#73) — зелёные без изменений поведения |
| AC8 | Выполнен | `reducedMotionIsImmediate` (`true`); `_onMotionChange` вызывает `_cancelCameraTransition(true)` при включении reduced motion, `_startCameraTransition` передаёт `duration=0` при `_reducedMotion` |
| AC9 | Выполнен | `buttonPersistsOnce`/`wheelStreamPersistsOnce` (`saves===1`); `_saveZoom()` вызывается только из `_settleCameraTransition`, один раз на settle |
| AC10 | Выполнен | `projectionCancelsAndIsoSettlesExactly` — итоговый `viewBox`/`zoom` точно совпадает с независимо вычисленным `_cameraTargetAt` |
| AC11 | Выполнен | `glowAndStructuralFrameStayStable` (`JSON.stringify` до/после равны); `smoke_glow`, `smoke_glow_geometry_resilience` зелёные |
| AC12 | Выполнен | `disconnectedCallback` вызывает `this._cameraTransition.dispose()` (houseplan-card.ts:2745); unit `viewport-transition.test.mjs` — `dispose()`→`cancel(false)` не оставляет RAF (`fake.callbacks.size===0` в тесте «cancel policies») |
| AC13 | Deferred (не автотест здесь) | То же решение, что в r1 spec- и code-review: закрывается предрелизным Full Performance/screencast гейтом, не циклом ревью. Косвенно риск закрыт `glowAndStructuralFrameStayStable` |
| AC14 | Выполнен | `coldViewDoesNotLoadEditorRuntime` (`true`, проверка через `performance.getEntriesByType('resource')`); `src/houseplan-editor-runtime.ts` не тронут диапазоном |
## Дельта сверх того, что видел r1
Сверх `c465eae8`/`54403704`/`b3abfc67` (уже одобрены r1 под именами
`895b120e…779382af`) в диапазон добавлено:
- `a945ca47` — `demo/docs/capture.mjs`: ждёт `card._cameraTransition?.active`
перед снимком, иначе бросает. Тестовая инфраструктура, поведение продукта
не меняет.
- `64f54fd4` — **единственный коммит с правкой продуктового кода** сверх
того, что видел r1: `_fitFar()`/`_fitAll()` получили безусловный
`this.requestUpdate()` перед `_resetZoom(...)` (houseplan-card.ts:5975,
5985). Причина: `_showFar` — обычное приватное поле, не Lit `@state()`
(houseplan-card.ts:5755), поэтому раньше рендер после клика по far-hint
зависел от того, что `_resetZoom()` синхронно меняла `_zoom`/`_view` и тем
самым просила update. С #82 `_startCameraTransition` может вернуть `false`
без единого вызова хуков (когда camera-target уже совпадает с текущим) —
и тогда без явного `requestUpdate()` смена `_showFar` не попала бы в
рендер. Остальные правки того же коммита — `await` на устаканивание камеры
в `demo/golden/harness.mjs`, `smoke_canvas_frame`, `smoke_infinite_canvas`,
`smoke_kiosk` — тестовая инфраструктура.
**Low, не блокирует:** конкретно этот no-op-путь (far-hint нажат, когда
camera-target уже равен текущему — например повторный клик до истечения
предыдущего fit-far) не покрыт отдельной smoke-проверкой; существующие
тесты far-hint (`smoke_infinite_canvas.hintGoneAfterShow`) всегда бьют по
сценарию с реальным движением камеры. Правка защитная и дешёвая
(лишний `requestUpdate()` не создаёт видимых артефактов), риск регрессии
низкий, поэтому не блокирует зелёный вердикт.
- `4c4399a7`, `4e24e266`, `9171644f` — три последовательных пересъёма
doc-скриншотов (`08-room-card.png`, `09-device-info.png`,
`06-device-editor.png`) с обновлением `screenshots.json`; `check-docs.mjs`
зелёный на финальном состоянии.
- `b5324ce4` — 8 golden-baseline из `origin/dev`, подтверждены прогоном
golden:verify выше (не только заявлением коммита).
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
| --- | --- | --- |
| `55cf36d6`/`b3abfc67 fix: preserve structural adoption guard` — по факту перестановка двух независимых операторов без наблюдаемой разницы поведения, замечание только к формулировке сообщения коммита | Не блокировала r1, не блокирует и сейчас; независимо перечитан diff коммита `b3abfc67` в этом раунде — подтверждаю: переставлен только порядок `this._cancelCameraTransition(false)` относительно очистки `_geometryHistory`/`_devicePositionHistory`/`_pendingPhysicalWrites`, эти операции не пересекаются по состоянию | `git show b3abfc67 -- src/houseplan-card.ts` |
| AC13 закрыт только предрелizным Full Performance гейтом, не автотестом в цикле | Тот же прецедент подтверждён и на этом SHA — решение не изменилось | Раздел AC13 выше |
## Унаследовано из r1
Поскольку ребейз на ушедший вперёд `dev` требует полного разбора (§7.2), из
r1 ничего не принято «на слово» без собственной перепроверки в этом
раунде — весь диапазон, включая уже одобренные r1 коммиты, перечитан заново
и его гейты (golden, smoke, check-docs, build) перезапущены в этом раунде.
Без повторной проверки принято только решение уровня продукта/процесса,
которое не является предметом код-ревью:
- продуктовая корректность ТЗ (структура §7.1, формулировки AC, отсутствие
недекларированных догадок) — из `SPEC-REVIEW-82-r1.md`
(`docs/reviews/SPEC-REVIEW-82-r1.md`, ТЗ на SHA `141d79a9`); код-ревью не
переоценивает качество ТЗ, только соответствие ему;
- прецедент «AC13 закрывается предрелизным Full Performance гейтом» — принят
ещё в `SPEC-REVIEW-82-r1.md` и переподтверждён `CODE-REVIEW-82-r1.md`; в
этом раунде не пересматривается заново, только сверено, что новых
Glow/geometry-путей в дельте нет (см. выше).
## Итог
High: 0. Medium: 0. Low: 3 (2 унаследованы и переподтверждены, 1 новая —
непокрытый smoke-тестом no-op-путь `_fitFar()`/`_fitAll()`, не блокирует).
Все AC1–AC12, AC14 подтверждены исполненным автотестом или разобраны по коду
с указанием строк; AC13 корректно отложен на предрелизный гейт по уже
принятому прецеденту. Вердикт: зелёный.
+183
View File
@@ -0,0 +1,183 @@
# SPEC-REVIEW-82-r1 — Плавное масштабирование плана (issue #82)
- **Issue:** https://github.com/Matysh/houseplan-card/issues/82
- **Артефакт ТЗ:** `docs/specs/082-smooth-zoom.md`, коммит `141d79a923f5703eb3beceebdabbd5508821f0fe`
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4 (полный трек, лимит 4)
- **Ревьюер:** Claude, роль «Ревьюер ТЗ» (PROCESS.md §2.4, §6)
## Скоуп ревью
Задача полного трека (нет метки `small`), поэтому ТЗ живёт файлом
`docs/specs/082-smooth-zoom.md`, а не в теле issue. Это первый заход
ревью на этом ТЗ (r1), поэтому §2.10 (разбор по дельте) не применяется —
разбор полный. Диф на пути к текущему SHA:
```
git diff 453c3e3d..141d79a9 -- docs/specs/082-smooth-zoom.md # 352+/232- строк, актуализация ТЗ
```
Диф не трогает `src/**`, `custom_components/**`, `test/**`, `demo/**` —
только `docs/specs/082-smooth-zoom.md`. Класс C (документация), гейты
типа `typecheck`/`test`/`build`/`check-docs` к этому диффу неприменимы:
менять нечего, кода не существует. См. «Чего не проверял».
## Как проверялось
1. Прочитан `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1, §2.3–2.10, §4,
§5, §7, §12).
2. Прочитано тело issue #82 и все три комментария (аналитика 2026-08-14,
заведение ТЗ 2026-08-15, актуализация 2026-08-30).
3. Прочитан полный текущий текст `docs/specs/082-smooth-zoom.md` (24
раздела).
4. Прочитан канонический `docs/CANVAS.md` (§5 Zoom and pan, «View/editor
camera handoff», §4 content frame) и `docs/TOUCH-SUPPORT.md` (raздел
про View/editor visual transition, deliberate degradation rule).
5. Проверены фактические утверждения ТЗ о текущем коде против
`src/houseplan-card.ts` (`_zoomAt`, `_onWheel`, `_stepZoom`,
`_resetZoom`, `_fitAll`, `_fitFar`, `_baseVb`, `_clampView`,
`ZOOM_MAX`/`ZOOM_MIN`, `LS_ZOOM`, `_saveZoom`, lazy `import('./houseplan-editor-runtime')`)
и `src/mode-transition.ts` (`ModeTransitionController`,
`interpolateModeVisualState` — уже интерполирует `pixelsPerUnit` в
log-space и center линейно тем же easing, что подтверждает
реализуемость предложенного §9 механизма).
6. Проверена терминология кнопок/подсказок против `src/i18n/ru.json`
(`title.zoom_fit` = «Вписать всё», `canvas.show_far` = «Показать») —
совпадает с матрицей §6 ТЗ.
7. Проверено существование смоков, на которые ссылается план тестов
(`demo/smoke_mode_transition.mjs`, `demo/smoke_visual_continuity.mjs`,
`demo/smoke_zoom_out.mjs`, `demo/smoke_pan_any_zoom.mjs`,
`demo/screencast_visual_continuity.mjs`, `npm run continuity:screencast`
в `docs/TESTING.md`) — не изобретены, это существующий инструментарий
#73.
8. Проверены обязательные разделы ТЗ по PROCESS.md §7.1 построчно
(список ниже).
## Проверка обязательных разделов (§7.1)
| Раздел §7.1 | Есть в ТЗ | Где |
| --- | --- | --- |
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после | ✅ | §2 |
| Проблема | ✅ | §3 |
| Скоуп и не-скоуп | ✅ | §4 (цели) + §5 (не входит) |
| Контракт поведения / UX | ✅ | §6–§13 |
| Модель данных и миграция | ✅ | §14 — «не меняется, миграции нет» |
| i18n | ✅ | §20 — новых ключей нет |
| AC1…ACn с доказательством | ✅ | §17, 14 критериев, у каждого назван способ |
| План автотестов | ✅ | §18 (unit / browser smoke / golden-perf) |
| Риски | ✅ | §22, 8 рисков с мерой |
| Откат | ✅ | §23 |
| Release-артефакты | ✅ | §21 |
Все обязательные разделы присутствуют и содержательны, не переписывают
issue дословно.
## Находки
Блокирующих (High) находок нет. В скоупе-Medium находок нет. Вне
скоупа — нет.
Ниже — три Low-наблюдения; ни одно не требует правки ТЗ, оставлены с
записью решения ревьюера (не блокируют переход в `S5-ready`).
### Low-1 — AC13 частично доказывается гейтом, недоступным на этой стадии цикла
`AC13` называет доказательством «Performance profile + screencast
smoke». Per `docs/STATUS.md`, CDP-скриншот-профиль (`continuity:screencast`)
— **stable-release-only** гейт, а не гейт код-ревью или даже бета-гейта;
то есть формально AC13 не может быть закрыт автотестом до промоушена в
стабильный релиз. Это не изобретение автора ТЗ: тот же паттерн уже
принят для #73 (`docs/superpowers/specs/2026-08-10-plan-visual-continuity-design.md`
явно делит проверку на обязательный CI rAF sampler и pre-stable CDP
screencast). Решение ревьюера: не блокировать — прецедент уже
принят продуктом, а перформанс-часть AC13 (RAF/rebuild-счётчики) всё
равно доказывается раньше, в pre-beta профиле (§8). Отметить только,
что на код-ревью AC13 будет разобран по коду плюс перформанс-профиль,
а screencast-часть закроется не раньше стабильного гейта — это стоит
явно писать в хендоффе, а не подразумевать.
### Low-2 — общий easing-хелпер между `ModeTransitionController` и новым camera controller не специфицирован технически
§24 помечает разделение camera/mode-контроллеров с общим easing как
«принято предположительно, поменять свободно» — формально корректно
по PROCESS.md §7.1 (технический вопрос, не продуктовый). Замечание
чисто наблюдательное: `interpolateModeVisualState` в
`src/mode-transition.ts:124-156` уже реализует ровно ту же схему
(log-space для масштаба, линейно для центра, общий `ease()`), так что
риск, который §22 таблицы называет «Wheel ощущается медленным» /
«SVG и HTML расходятся», технически прецедентно снят — эта же формула
уже работает в проде для перехода режимов. Не требует правки ТЗ.
### Low-3 — «дальний hint «Показать»» добавлен в матрицу §6, не упомянутый в исходном тексте issue
Матрица §6 ТЗ добавляет девятую строку («Far hint «Показать»»)
относительно восьми строк в исходном issue. Проверено по коду:
`_fitFar()` (`src/houseplan-card.ts:5879`) сам вызывает `_resetZoom()`
— тот же примитив, что и «Вписать всё»/home-arrow/double-tap, так что
это не новая функциональность, а актуализация того, что «Вписать всё» в
issue уже подразумевало устройство разных источников одного и того же
действия. Не расширение скоупа, не требует нового вопроса владельцу.
## Что проверено и корректно
- Все фактические утверждения раздела 3 «Актуальность и проблема» о
текущем коде (`_zoomAt`, `_onWheel`, `_stepZoom`, `_resetZoom`,
`_fitAll`/`_fitFar`, lazy editor runtime, общий `_view` для
flat/isometric) — сверены с исходным кодом и точны.
- Константы `MIN_ZOOM = 1/3`, `ZOOM_MAX = 8`, easing
`cubic-bezier(0.2, 0.7, 0.2, 1)` — совпадают с `docs/CANVAS.md` §5 и
`src/mode-transition.ts`.
- Persistence-контракт §13 («editor zoom не пишется в View intent»,
«`_saveZoom()` работает только в `mode === 'view'`») сверен с
`src/houseplan-card.ts:6212-6228` — комментарий в коде подтверждает
тот же инвариант дословно.
- Терминология кнопок («Вписать всё», «Показать») сверена с
`src/i18n/ru.json` — совпадает буквально.
- `docs/TOUCH-SUPPORT.md` не противоречит требованию §12 «stage не
получает overlay/inert» во время camera-only tween: канонический
`inert` относится к отдельному, другому контракту View/editor
mode-перехода (#101), а не к zoom-действию внутри режима — ТЗ
корректно разграничивает эти два случая в §8.
- AC1–AC14 однозначны, у каждого указан способ доказательства; ни
одна формулировка не читается как два разных критерия одновременно.
- Раздел 24 «Принятые технические предположения» корректно отделяет
технические решения от продуктовых; продуктовых вопросов к
владельцу действительно не осталось — сценарии из issue (interruption
matrix, accessibility, no-op) уже были фактами, подтверждёнными
комментарием владельца 2026-08-14 («вопросы: нет»).
- Раздел «Не входит в задачу» корректно исключает inertia/kinetic pan,
анимацию mode/space/projection-переходов, новый storage-формат,
что не даёт скоупу расползтись на смежные issue (#101, #73, #89/122).
- Двунаправленные ссылки issue ↔ ТЗ на месте (`docs/specs/README.md`
строка 115, тело ТЗ строка 3).
## Чего не проверял
- **Код-гейты `typecheck`/`test`/`build`/`check-docs` не прогонялись** —
диф этого раунда состоит только из `docs/specs/082-smooth-zoom.md`
(класс C), продуктового кода/тестов нет, прогонять нечего. Это
сознательное решение по объёму, а не пропуск: `git diff --stat`
выше подтверждает единственный изменённый файл.
- Не проверялась реализуемость точной wheel-anchor математики
(0.5 CSS px) на реальном RAF-цикле — это вопрос будущей реализации
и код-ревью («тест умеет падать»), не спецификации: ТЗ формулирует
требование измеримо (AC4, unit на presented-state retarget), способ
доказательства назван.
- Не запускал browser/perf/golden-смоки — на этой стадии кода нет,
им неоткуда взяться; они будут частью реализации (§18) и предметом
код-ревью.
- Не проверял детально `iso-projection.ts` построчно на предмет
скрытых зависимостей от zoom помимо общего `_view`/`_zoom`
состояния — ограничился подтверждением, что модуль не содержит
собственных ссылок на `viewBox`/`zoom` (grep пуст), что достаточно
для вывода «камера едина для flat/iso» из §9/AC10.
## Вердикт
ТЗ полное, разделы §7.1 закрыты, критерии приёмки однозначны и
снабжены способом доказательства, продуктовых вопросов к владельцу не
осталось, фактические утверждения о текущем коде и канонических
документах проверены и точны. Блокирующих находок нет.
**Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 ·
Medium: 0 → в задаче**
+402
View File
@@ -0,0 +1,402 @@
# Issue #82 — плавное масштабирование плана
- **Issue:** https://github.com/Matysh/houseplan-card/issues/82
- **Актуализировано:** 2026-08-30 для `dev` / v1.69.0
- **Приоритет:** P2
- **Тип:** feature / polish, полный трек
- **Пользовательское изменение:** да
## 1. Сценарий и персона
Житель или гость рассматривает дом в View либо kiosk и приближает нужный
фрагмент колесом, кнопками или двойным tap. Администратор делает то же в одном
из desktop-first редакторов, чтобы точнее разместить объект. В обоих случаях
дискретное изменение масштаба сейчас происходит мгновенно, и глаз на короткое
время теряет объект и направление движения камеры.
View и kiosk остаются полностью поддерживаемыми на touch. Редакторы остаются
desktop-first по `docs/TOUCH-SUPPORT.md`; задача не расширяет обещание полного
touch-редактирования.
## 2. Что человек увидит до и после
**До:** колесо, кнопки, «Вписать всё», стрелка возврата к потерянному плану и
двойной tap скачком заменяют показанный фрагмент.
**После:** те же действия за 160–220 мс плавно ведут к тому же конечному
фрагменту, а pan и pinch по-прежнему без задержки следуют за рукой.
## 3. Актуальность и проблема
Задача полностью актуальна на текущем `dev`:
- `_zoomAt()` немедленно заменяет `_zoom` и `_view`;
- `_onWheel()` и `_stepZoom()` сразу вызывают `_zoomAt()`;
- `_resetZoom()` сразу ставит zoom `1` и текущий content fit;
- `_fitAll()`, `_fitFar()`, home-arrow и kiosk double-tap проходят через
`_resetZoom()`;
- `ModeTransitionController` из #101 плавно меняет режимы, но намеренно не
обслуживает zoom внутри одного режима;
- интерактивная камера находится в основном `houseplan-card`, а lazy
`houseplan-editor-runtime` лишь вызывает её контракты. Для View не требуется
загружать editor chunk;
- flat и isometric уже используют одну текущую `_view`; новый переход обязан
работать в координатах активной проекции и не менять их преобразование.
Проблема относится к частой навигации J1/J2 из `docs/SCOPE.md`. Это не
критическая ошибка, но заметный polish: короткое движение сохраняет
пространственный контекст и делает дорогой визуально продукт спокойнее.
## 4. Цели
1. Плавно анимировать только дискретные пользовательские zoom/fit/reset.
2. Объединять rapid wheel в один retargetable переход без очереди.
3. Сохранить точную текущую математику zoom, anchor, fit, clamp и projection.
4. Оставить pinch и pan прямыми 1:1.
5. Согласовать camera transition с #73, #101, warm remount, lazy editor и
visual continuity.
6. Не перезапускать Glow, device resolution или тяжёлую геометрию на кадрах
камеры.
## 5. Не входит в задачу
- inertia, kinetic pan, bounce и overscroll;
- плавный pan как самостоятельная функция;
- focus-on-room и другие новые команды камеры;
- анимация пространства, flat ↔ isometric, View ↔ editor, initial mount,
resume или reconnect;
- изменение `MIN_ZOOM`, `ZOOM_MAX`, `PAN_SLACK`, content frame либо outlier
policy;
- изменение относительных размеров marker, furniture strokes или подписей;
- настройка duration/easing пользователем;
- новый storage/config/backend формат;
- изменение визуальной семантики Glow или degradation изображения на время
zoom;
- интерактивный zoom в `houseplan-space-card`: у static card его нет.
## 6. Матрица поведения
| Источник | Результат | Якорь | Длительность |
| --- | --- | --- | --- |
| Кнопки `−` / `+` | один плавный zoom step | центр stage | 180 мс |
| Mouse wheel | retargetable zoom | текущая позиция курсора | до 160 мс после последнего input |
| Trackpad wheel stream | один непрерывно retargetable tween | текущая позиция курсора | без очереди |
| «Вписать всё» / средняя кнопка | к актуальному core frame | центр target frame | 220 мс |
| Far hint «Показать» | к frame со всеми outliers | центр target frame | 220 мс |
| Home-arrow | к актуальному frame | центр target frame | 220 мс |
| Kiosk double-tap | к zoom `1` / fit | центр target frame | 220 мс |
| Pinch | без tween | midpoint пальцев | direct |
| Pan / swipe | без tween и inertia | pointer | direct |
Duration — compile-time UI constants. После инструментального измерения их
можно унифицировать внутри 160–220 мс без нового продуктового решения, если
сохраняется ощущение короткого перехода.
## 7. Сценарии без анимации
Viewport применяется атомарно при:
- initial mount, чтении сохранённого zoom и cold/warm restore;
- visibility resume, reconnect и continuity recovery;
- смене пространства;
- flat ↔ isometric;
- View ↔ editor и editor ↔ editor: ими владеет #101;
- `ResizeObserver`, изменении toolbar/context-tray height и resize окна;
- принятии config/layout revision и изменении content frame;
- нулевом или нестабильном stage;
- `prefers-reduced-motion: reduce`.
Эти пути не получают промежуточный fit flash, veil или отложенное проигрывание
старого camera target.
## 8. Единый camera-only controller
Добавляется pure camera transition controller, отдельный от
`ModeTransitionController`, но использующий ту же perceptual easing и тот же
принцип одного RAF/token owner.
```ts
interface CameraState {
zoom: number;
viewBox: { x: number; y: number; w: number; h: number };
}
interface CameraTransition {
from: CameraState;
to: CameraState;
startedAt: number;
durationMs: number;
reason: 'button' | 'wheel' | 'fit' | 'home' | 'double-tap';
}
```
Компонент остаётся единственным владельцем reactive `_zoom` и `_view`.
Controller вычисляет представленный кадр и lifecycle, но не знает о Lit,
localStorage, режимах или Home Assistant.
Одновременно действует не более одного владельца камеры:
- camera transition не запускается во время подготовки/running перехода
режима;
- начало mode/space/projection/structural transition отменяет camera
transition согласно §11;
- camera transition не меняет `_modeTransitionVisual`, размеры chrome,
background или opacity слоёв;
- core View не импортирует lazy editor runtime ради анимации.
## 9. Target и интерполяция
Target строится до запуска перехода существующими `fitView`, `_baseVb()`,
`MIN_ZOOM`, `ZOOM_MAX`, `_clampView()` и anchor-математикой `_zoomAt()`.
Альтернативной формулы fit/clamp у #82 нет.
- easing соответствует `cubic-bezier(0.2, 0.7, 0.2, 1)` из #101;
- zoom интерполируется в log-space;
- camera center интерполируется линейно с тем же eased progress;
- width/height выводятся из интерполированного zoom и текущего aspect;
- промежуточный кадр проходит текущий clamp;
- последний кадр присваивает exact target, исключая накопленную ошибку;
- NaN, infinity, вырожденный stage или отсутствие RAF ведут к безопасному
immediate target, а не к сломанному DOM;
- pure helpers принимают управляемый clock/progress для детерминированных
unit-тестов.
Независимый CSS `transform: scale()` запрещён как конечный или временный
источник истины: он иначе масштабирует SVG strokes, blur, HTML markers и hit
targets и создаёт финальный commit jump.
## 10. Wheel retargeting и anchor
Каждое новое wheel-событие:
1. берёт реально представленный camera state running tween;
2. накапливает zoom от предыдущего target, а не от запаздывающего кадра;
3. вычисляет world-point под актуальным pointer в представленном viewport;
4. строит новый target так, чтобы этот point остался под pointer;
5. заменяет transition без queue и второго RAF;
6. допускает немедленный разворот направления.
Без clamp anchor остаётся на месте с ошибкой не более 0.5 CSS px. При clamp
смещение допускается только на ограниченной оси. `deltaMode` нормализуется так,
чтобы line/page wheel не создавал многосекундную очередь; текущий один event =
один factor остаётся совместимым.
## 11. Прерывания и конкуренция
- Новый discrete zoom retarget-ит running camera transition.
- Pointerdown, который начинает pan, pinch, draw, drag или selection, сначала
фиксирует представленный кадр и отменяет tween без скачка.
- Pinch всегда использует immediate `_zoomAt()` и не получает post-animation.
- Mode/space/projection change, resize, config/layout adoption, continuity
recovery и viewport restore отменяют camera transition и выполняют свой
существующий атомарный контракт.
- Если документ становится hidden, user target коммитится сразу; при возврате
старый tween не продолжается.
- `disconnectedCallback()` отменяет RAF и очищает transition state.
- Изменение media query на reduced motion во время tween коммитит target сразу.
- Повторное действие на точном min/max или exact fit — no-op: RAF и запись
storage не создаются.
- Escape не отменяет самостоятельный wheel/button zoom, если не запускает
действие, которое само владеет viewport.
## 12. Интерактивность и слои
Во время camera tween stage не получает overlay или `inert`:
- SVG, HTML markers, room labels, hit targets, Glow и editor overlays читают
один представленный `_zoom` / `_view`;
- zoom badge показывает текущий нарисованный процент и exact target после
settle;
- hover, tooltip и selection не отстают от пикселей;
- pointerdown продолжает действие из реально показанной камеры;
- opening/furniture/draw previews очищаются один раз при zoom-команде либо
прямом жесте, а не на каждом RAF;
- click-through по прежнему положению объекта невозможен.
Glow source resolution, opacity, blending, shadows и live fade не меняются.
Camera frames не меняют structural fingerprint, config/layout epoch, device
graph, wall model, projection cache или Glow source set. Нельзя временно
скрывать backdrop, стены, decor, устройства либо эффекты ради скорости.
## 13. Persistence
- View записывает итоговый target после settle, один раз на transition.
- Retargetable wheel stream также даёт одну финальную запись.
- Editor zoom не записывается в View intent.
- Формат `LS_ZOOM` и warm viewport memo не меняется.
- Structural cancellation не сохраняет stale camera target.
- Immediate pinch/pan используют существующий gesture persistence contract;
его отдельная оптимизация не входит в #82.
- No-op не переписывает localStorage.
## 14. Модель данных, миграция и compatibility
Server config, layout, backend API, normalized model и файлы плана не меняются.
Новых сохраняемых полей нет, миграция отсутствует. Старые и новые карточки
читают один и тот же `LS_ZOOM`; отличие только presentation-time.
## 15. Touch и accessibility
- Pinch остаётся без lag и строго следует midpoint.
- Kiosk double-tap анимируется только после подтверждённого single-pointer
gesture и не конкурирует с pinch/swipe lock.
- `prefers-reduced-motion: reduce`, включая его изменение во время перехода,
всегда ведёт к immediate exact target.
- Keyboard activation кнопок использует тот же transition path.
- Анимация не переносит DOM focus и не меняет accessible names.
- View/kiosk touch smoke release-blocking; touch editors — safety regression
check по `docs/TOUCH-SUPPORT.md`.
## 16. Edge cases
- точные `MIN_ZOOM = 1/3` и `ZOOM_MAX = 8`;
- zoom `1` со смещённым center;
- content полностью вне viewport и home-arrow;
- core frame против all/outlier frame;
- wide, tall, diagonal, line-only и degenerate content;
- пустое пространство с fallback `view_box`;
- rapid wheel в обе стороны и меняющийся anchor;
- wheel + pointerdown в одном frame;
- pinch во время tween;
- resize toolbar/stage, mode/space switch и projection switch;
- visibility hidden сразу после старта;
- flat/isometric, light/dark, kiosk, View и три editor mode;
- отсутствующий RAF/performance clock в тестовом окружении.
## 17. Acceptance criteria и доказательства
| AC | Критерий | Обязательное доказательство |
| --- | --- | --- |
| AC1 | `−/+`, wheel, fit, far-fit, home и kiosk double-tap имеют хотя бы один промежуточный camera frame и прежний exact target | Unit + browser smoke |
| AC2 | Pinch и pan остаются direct, без lag и post-animation | Browser smoke |
| AC3 | Все SVG/HTML/effect/hit слои используют один camera state; видимый объект кликается во время tween | Browser smoke + code review |
| AC4 | Rapid wheel использует один RAF, retarget без queue, сохраняет pointer anchor и допускает reversal | Unit + browser smoke |
| AC5 | Exact min/max/fit no-op не запускает RAF и не пишет storage | Unit |
| AC6 | Pointer, mode, space, projection, resize, adoption, hidden и disconnect завершают/отменяют transition по матрице §11 без jump | Unit + browser smoke |
| AC7 | Mount/resume/reconnect/warm restore остаются атомарными и не проигрывают старый target | Browser smoke continuity regression |
| AC8 | Reduced motion до и во время tween даёт immediate exact target | Unit + browser smoke |
| AC9 | View пишет один итоговый zoom; editor и cancelled structural target не пишут View intent | Unit + browser smoke |
| AC10 | Flat и isometric приходят к тем же конечным viewBox/zoom, что до #82 | Unit + browser smoke |
| AC11 | Glow source count, opacity/blend, hover, backdrop и device state не меняются из-за camera frame | Browser smoke + performance counters |
| AC12 | Disconnect не оставляет RAF, timer или listener | Unit |
| AC13 | Heavy flat/isometric/Glow fixtures завершают transition без blank, black, stale frame и без structural rebuild на RAF | Performance profile + screencast smoke |
| AC14 | Основной View не запрашивает lazy editor chunk только из-за zoom | Browser network assertion |
## 18. План автотестов
### Unit
Новый `test/viewport-transition.test.mjs` проверяет:
- easing/interpolation start, mid, end и exact target;
- monotonic log zoom и linear center;
- anchor preservation, clamp exception и aspect;
- retarget from presented state, accumulated target и reversal;
- единственный RAF/no queue;
- min/max/fit no-op;
- reduced-motion/immediate fallback;
- cancel, commit-target, hidden и dispose;
- invalid/degenerate inputs.
Существующие `mode-transition.test.mjs` остаются зелёными; общая easing helper
не может изменить их согласованные кадры.
### Browser smoke
Новый `demo/smoke_smooth_zoom.mjs` на production component проверяет AC1–AC12
и AC14. Clock управляется через controller/harness, поэтому assertions не
зависят от попадания в случайную wall-clock миллисекунду.
Регрессии выбираются через `npm run smokes:select`; обязательный минимум:
- zoom-out / kiosk pan-lock;
- #73 visual continuity / warm remount;
- #101 View/editor mode transition;
- flat/isometric projection switch;
- heavy Glow camera path;
- opening/furniture preview interruption.
### Golden и performance
Final pixels не меняются, поэтому новые golden baselines не принимаются.
Плавность доказывается deterministic DOM/screencast smoke с промежуточным
кадром. На canonical heavy Glow и large-house flat/isometric profiles:
- один camera RAF;
- ноль device/registry/wall/projection/Glow-source rebuild на frame;
- settle не позже duration + двух реально доступных кадров после main-thread
stall;
- нет blank/black frame и continuity overlay;
- действующие bundle и runtime budgets не ухудшаются сверх их допуска.
## 19. Затронутые модули
Планируемые точки изменения:
- `src/viewport-transition.ts` — pure camera controller/helpers;
- `src/mode-transition.ts` — только общий экспорт easing primitive, если это
позволит избежать расхождения двух одинаковых curves;
- `src/houseplan-card.ts` — target calculation, lifecycle, handlers и
interruption boundary;
- `src/houseplan-editor-runtime.ts` — только host-port/cancellation call, если
этого потребует mode switch; бизнес-логика zoom остаётся в core;
- unit и browser smoke из §18;
- документация и changelog из §21.
## 20. i18n
Новых подписей, кнопок и сообщений нет. Новые i18n keys не требуются. Текущие
RU/EN/DE/FR accessible names и tooltips остаются неизменными.
## 21. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: короткий user-visible пункт со
ссылкой на #82;
- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`: таблица zoom/gesture и
reduced-motion;
- `docs/CANVAS.md`: camera-only animator, ownership и interruption contract;
- `docs/TESTING.md`: deterministic transition и continuity regressions;
- `docs/ARCHITECTURE.md`: один абзац о разделении mode и camera controllers;
- `docs/STATUS.md`: обновить feature surface, если текущая политика релизной
ветки требует;
- docs screenshot fingerprint обновляется штатным CI/accept flow; новые
пользовательские screenshots не нужны, поскольку финальный кадр тот же;
- перед beta выполняются штатные Full Performance, browser/golden и touch
release gates. Golden baseline не переакцептуется.
## 22. Риски и меры
| Риск | Мера |
| --- | --- |
| Два controller одновременно пишут `_view` | строгая взаимная отмена mode/camera ownership |
| SVG и HTML расходятся | только reactive `_zoom`/`_view`, без CSS scale |
| Wheel ощущается медленным | retarget от presented state и target accumulation |
| Pointer выбирает старую позицию | cancel/freeze at presented frame на pointerdown |
| Glow/geometry rebuild на каждом RAF | counters в smoke/performance и стабильные fingerprints |
| Resume проигрывает старый tween | explicit hidden/disconnect/continuity cancellation |
| Lazy editor попадает в View bundle | controller живёт в core, network assertion AC14 |
| Isometric camera drift | exact old target и projection regression AC10 |
## 23. Откат
Откат возвращает handlers к immediate `_zoomAt()` / `_resetZoom()` и удаляет
camera controller. Config, layout, localStorage schema и backend не требуют
обратной миграции. Если performance gate выявляет недопустимую стоимость,
feature можно целиком откатить одним frontend commit без потери данных.
## 24. Принятые технические предположения
Эти решения не являются отдельным продуктовым контрактом и могут быть изменены
по ревью:
- camera controller отделён от `ModeTransitionController`, но curve helper
общий;
- `_zoom` отражает представленный кадр, отдельное поле controller хранит target
для wheel accumulation;
- структурная навигация имеет приоритет над camera tween;
- current stage aspect authoritative на каждом frame;
- финальный target вычисляется существующей камерой до запуска tween;
- #152 либо будущий focus-on-room сможет переиспользовать target API, но не
входит в реализацию #82.
Продуктовых вопросов к владельцу нет.
+1
View File
@@ -112,6 +112,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#68](https://github.com/Matysh/houseplan-card/issues/68) Подсказки к настройкам | [068-help-affordance.md](068-help-affordance.md) |
| [#74](https://github.com/Matysh/houseplan-card/issues/74) Position-only Undo/Redo устройств | [074-device-position-undo.md](074-device-position-undo.md) |
| [#75](https://github.com/Matysh/houseplan-card/issues/75) + [#76](https://github.com/Matysh/houseplan-card/issues/76) Единый поток размещения проёмов | [075-076-opening-placement-flow.md](075-076-opening-placement-flow.md) |
| [#82](https://github.com/Matysh/houseplan-card/issues/82) Плавное масштабирование zoom/fit/reset | [082-smooth-zoom.md](082-smooth-zoom.md) |
| [#84](https://github.com/Matysh/houseplan-card/issues/84) Источник «Всегда» без сущности HA + [#88](https://github.com/Matysh/houseplan-card/issues/88) ведущая сущность | [084-passive-forced-light-sources.md](084-passive-forced-light-sources.md) |
| [#86](https://github.com/Matysh/houseplan-card/issues/86) Подсказки к настройкам, партия 1 | [086-settings-help-content-party1.md](086-settings-help-content-party1.md) |
| [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) |
+154 -24
View File
@@ -293,6 +293,10 @@ import {
ModeTransitionController, viewportFromViewBox,
type HouseplanMode, type ModeTransitionState, type ModeVisualState, type ModeViewBox,
} from './mode-transition';
import {
CameraTransitionController, cameraTargetAtAnchor, sameCameraState,
type CameraState, type CameraTransitionReason, type CameraTransitionState,
} from './viewport-transition';
import { EditorRuntimeLoader, lazyLoadFailureMessage, type EditorRuntimeLoaderState } from './editor-runtime-loader';
import type { BackdropGuardState } from './backdrop-pick';
@@ -406,6 +410,9 @@ const BOOT_MAX_MS = 1200;
/** AUD-1552-02: post-reveal grace during which late chrome shifts glide
* (CSS height transition on the stage) instead of snapping. */
const BOOT_SOFT_MS = 1500;
const CAMERA_BUTTON_MS = 180;
const CAMERA_WHEEL_MS = 160;
const CAMERA_FIT_MS = 220;
/** Numeric editor fields must consume the whole value: `50abc` is invalid,
* not a surprisingly accepted 50. Decimal comma remains supported. */
const strictNumber = (value: string): number | null => {
@@ -1149,6 +1156,7 @@ export class HouseplanCard extends LitElement {
private _onMotionChange = (event: MediaQueryListEvent): void => {
this._reducedMotion = event.matches;
this._cancelDevicePressFeedback();
if (event.matches) this._cancelCameraTransition(true);
// A preference change to reduced motion is authoritative immediately: do
// not leave a 220 ms camera/chrome tween running until its old deadline.
if (event.matches && this._modeTransitionPreparing) {
@@ -1179,6 +1187,13 @@ export class HouseplanCard extends LitElement {
frame: (state) => this._applyModeTransitionFrame(state),
settled: (state) => this._settleModeTransition(state),
});
/** #82: one camera-only transition inside a settled mode. It never owns
* chrome/background coordinates — those remain exclusive to #101. */
private _cameraTransitionFit: ModeViewBox | null = null;
private readonly _cameraTransition = new CameraTransitionController({
frame: (state) => this._applyCameraTransitionFrame(state),
settled: (state) => this._settleCameraTransition(state),
});
/** Explicit second-level toolbar group. No current tools are grouped yet;
* the shared host/API prevents future editors from inventing dropdowns. */
private declare _editorSecondary: import('./editor-secondary').EditorSecondaryController;
@@ -1192,6 +1207,72 @@ export class HouseplanCard extends LitElement {
return this._modeTransitionPreparing || this._modeTransition.active;
}
private _cameraState(): CameraState {
const view = this._viewOr(this._baseVb());
return { zoom: this._zoom, viewBox: { ...view } };
}
private _normalizeCameraState(state: CameraState): CameraState {
const fit = this._cameraTransitionFit || fitView(this._baseVb(), this._stageAspect());
const zoom = Math.min(HouseplanCard.ZOOM_MAX,
Math.max(HouseplanCard.ZOOM_MIN, state.zoom));
return { zoom, viewBox: this._clampView({ ...state.viewBox }, fit) };
}
private _applyCameraTransitionFrame(state: CameraTransitionState): void {
const presented = this._normalizeCameraState(state.presented);
// Retargeting must start from the camera that was actually painted, not
// from an unclamped interpolation hidden inside the controller.
state.presented = presented;
this._zoom = presented.zoom;
this._view = { ...presented.viewBox };
this.requestUpdate();
}
private _settleCameraTransition(state: CameraTransitionState): void {
const target = this._normalizeCameraState(state.to);
this._zoom = target.zoom;
this._view = { ...target.viewBox };
this._cameraTransitionFit = null;
this._saveZoom();
this.requestUpdate();
}
private _cancelCameraTransition(commitTarget = false): void {
this._cameraTransition.cancel(commitTarget);
this._cameraTransitionFit = null;
}
/** A discrete camera command may follow a rapid mode click. Finish that
* short structural transition first so two controllers never write `_view`
* in the same frame. */
private _prepareCameraCommand(): void {
if (this._modeTransitionBusy) this._cancelModeTransition(true);
if (this._tool === 'opening') {
this._cursorPt = null;
this._clearOpeningPlacement(false);
}
}
private _startCameraTransition(
target: CameraState,
fit: ModeViewBox,
reason: CameraTransitionReason,
duration: number,
): boolean {
const current = this._cameraState();
const runningTarget = this._cameraTransition.target;
if (runningTarget && sameCameraState(runningTarget, target)) return false;
if (sameCameraState(current, target)) {
this._cancelCameraTransition(false);
return false;
}
this._cameraTransitionFit = { ...fit };
this._cameraTransition.start(current, target, reason,
this._reducedMotion ? 0 : duration);
return true;
}
private _cssColor(value: string | null | undefined, fallback: string): string {
const text = String(value || '').trim();
if (!text) return fallback;
@@ -1317,6 +1398,7 @@ export class HouseplanCard extends LitElement {
}
private _cancelModeTransition(commitTarget = true): void {
this._cancelCameraTransition(false);
const hadControllerState = !!this._modeTransition.state;
this._modeTransitionRequest++;
this._modeTransitionPreparing = false;
@@ -1466,6 +1548,7 @@ export class HouseplanCard extends LitElement {
private _commitSpace(id: string, authority = false): boolean {
if (!this._canCommitSpace(id, authority)) return false;
if (id !== this._space) {
this._cancelCameraTransition(false);
this._clearTransientHover(true);
this._cancelDevicePressFeedback();
this._editorRuntime?._clearFurniturePreview();
@@ -2277,6 +2360,7 @@ export class HouseplanCard extends LitElement {
if (signal.kind === 'hidden') {
this._clearTransientHover(true);
this._cancelDevicePressFeedback();
this._cancelCameraTransition(true);
if (this._modeTransitionPreparing) {
// There is no measured controller endpoint to commit yet. Keep the
// already queued measurement but force its synchronous atomic path;
@@ -2658,6 +2742,8 @@ export class HouseplanCard extends LitElement {
this._editorRuntimeLoadingTimer = undefined;
this._editorRuntimeLoadingVisible = false;
this._modeTransition.dispose();
this._cameraTransition.dispose();
this._cameraTransitionFit = null;
this._modeTransitionVisual = null;
this._modeTransitionPreparing = false;
this._modeTransitionForceAtomic = false;
@@ -4096,6 +4182,10 @@ export class HouseplanCard extends LitElement {
this._devicePositionHistory.clear();
this._cancelDeviceDrag();
this._pendingPhysicalWrites.clear();
// #82: a new structural baseline owns the viewport. Freeze the last
// painted camera frame before replacing geometry so an obsolete target
// cannot settle against the new content frame.
this._cancelCameraTransition(false);
if (this._serverCfg) this._clearGeometryGesture();
this._serverCfg = nextConfig;
this._seedDecorStyle(this._serverCfg);
@@ -4123,6 +4213,7 @@ export class HouseplanCard extends LitElement {
layoutChanged = nextLayoutFingerprint !== (this._layoutContentFingerprint
|| contentFingerprint(this._layout));
if (layoutChanged) {
this._cancelCameraTransition(false);
this._devicePositionHistory.clear();
this._cancelDeviceDrag();
this._layout = nextLayout;
@@ -5879,16 +5970,20 @@ export class HouseplanCard extends LitElement {
private _fitFar(): void {
this._showFar = true;
this._frame = null;
// The camera command may be a no-op at the fitted target. The hint still
// changed state and must disappear without waiting for another render.
this.requestUpdate();
this._resetZoom();
}
/** «Вписать всё» (docs/CANVAS.md §8) — the toolbar button and the "home is
* that way" arrow share it. It fits whatever the frame currently means:
* the main mass, or everything once the far-objects hint has been used. */
private _fitAll(): void {
private _fitAll(reason: 'fit' | 'home' = 'fit'): void {
this._showFar = true;
this._frame = null;
this._resetZoom();
this.requestUpdate();
this._resetZoom(reason);
}
/** Unobtrusive inline hint: some objects stand an order of magnitude away
@@ -5919,7 +6014,7 @@ export class HouseplanCard extends LitElement {
const top = 50 + Math.sin(ang) * 38;
return html`<button class="homearrow" title=${this._t('canvas.home_tip')}
style="left:${left.toFixed(1)}%;top:${top.toFixed(1)}%"
@click=${(e: Event) => { e.stopPropagation(); this._fitAll(); }}>
@click=${(e: Event) => { e.stopPropagation(); this._fitAll('home'); }}>
<ha-icon icon="mdi:arrow-right-thick" style="transform:rotate(${((ang * 180) / Math.PI).toFixed(1)}deg)"></ha-icon>
</button>`;
}
@@ -5991,6 +6086,7 @@ export class HouseplanCard extends LitElement {
/** Set the zoom (centered on vb point cx,cy, or on the center of the current view). */
private _applyView(zoom: number, cx?: number, cy?: number): boolean {
this._cancelCameraTransition(false);
const vb = this._baseVb();
const fit = fitView(vb, this._stageAspect());
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, zoom));
@@ -6105,6 +6201,9 @@ export class HouseplanCard extends LitElement {
/** Recompute the view for a new scene size, preserving zoom and center. */
private _refitView(): void {
if (this._modeTransitionBusy || this._warmModeRequest) return;
// Resize is structural: keep the currently painted camera, cancel its old
// target, then let the existing refit path own the new stage geometry.
this._cancelCameraTransition(false);
const stage = this._stageEl;
// ResizeObserver may deliver a zero/transitional box while a browser tab
// is frozen or while Lovelace replaces the card. Mutating `_view` from
@@ -6160,53 +6259,82 @@ export class HouseplanCard extends LitElement {
});
}
/** Change the zoom while keeping the point (sx,sy relative to the scene) in place. */
private _zoomAt(sx: number, sy: number, newZoom: number): void {
private _cameraTargetAt(
sx: number,
sy: number,
newZoom: number,
): { target: CameraState; fit: ModeViewBox } | null {
const stage = this._stageEl;
if (!stage) return;
if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return null;
const vb = this._baseVb();
const fit = fitView(vb, this._stageAspect());
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, newZoom));
const target = cameraTargetAtAnchor(
this._cameraState(), z, fit,
stage.clientWidth, stage.clientHeight, sx, sy,
);
if (!target) return null;
target.viewBox = this._clampView(target.viewBox, fit);
return { target, fit };
}
/** Immediate path for direct pinch: keep the point under the fingers. */
private _zoomAt(sx: number, sy: number, newZoom: number): void {
this._cancelCameraTransition(false);
const result = this._cameraTargetAt(sx, sy, newZoom);
if (!result) return;
if (this._tool === 'opening') {
this._cursorPt = null;
this._clearOpeningPlacement(false);
}
const w = stage.clientWidth, h = stage.clientHeight;
const pt = this._screenToVb(sx, sy);
const nw = fit.w / z, nh = fit.h / z;
this._zoom = z;
this._view = this._clampView({ x: pt[0] - (sx / w) * nw, y: pt[1] - (sy / h) * nh, w: nw, h: nh }, fit);
this._zoom = result.target.zoom;
this._view = { ...result.target.viewBox };
}
private _onWheel(ev: WheelEvent): void {
this._prepareCameraCommand();
const stage = this._stageEl;
if (!stage) return;
ev.preventDefault();
const r = stage.getBoundingClientRect();
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
this._zoomAt(ev.clientX - r.left, ev.clientY - r.top, this._zoom * factor);
this._saveZoom();
const baseZoom = this._cameraTransition.target?.zoom ?? this._zoom;
const result = this._cameraTargetAt(
ev.clientX - r.left, ev.clientY - r.top, baseZoom * factor,
);
if (result) this._startCameraTransition(
result.target, result.fit, 'wheel', CAMERA_WHEEL_MS,
);
}
private _stepZoom(delta: number): void {
this._prepareCameraCommand();
const stage = this._stageEl;
if (!stage) return;
this._zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, this._zoom * (delta > 0 ? 1.4 : 1 / 1.4));
this._saveZoom();
const baseZoom = this._cameraTransition.target?.zoom ?? this._zoom;
const result = this._cameraTargetAt(
stage.clientWidth / 2,
stage.clientHeight / 2,
baseZoom * (delta > 0 ? 1.4 : 1 / 1.4),
);
if (result) this._startCameraTransition(
result.target, result.fit, 'button', CAMERA_BUTTON_MS,
);
}
/** «Вписать всё» (docs/CANVAS.md §8) — the toolbar's middle button and the
* old "reset zoom" in one: frame the content at zoom 1, centred. With
* `all` it also takes in the far strays the opening view leaves out. */
private _resetZoom(): void {
private _resetZoom(reason: 'fit' | 'home' | 'double-tap' = 'fit'): void {
this._prepareCameraCommand();
const vb = this._baseVb();
if (this._tool === 'opening') {
this._cursorPt = null;
this._clearOpeningPlacement(false);
}
this._zoom = 1;
this._view = fitView(vb, this._stageAspect());
this._saveZoom();
const fit = fitView(vb, this._stageAspect());
this._startCameraTransition(
{ zoom: 1, viewBox: { ...fit } },
fit,
reason,
CAMERA_FIT_MS,
);
}
/** Save the current space zoom to localStorage (view mode only). */
@@ -6229,6 +6357,7 @@ export class HouseplanCard extends LitElement {
/** Restore the saved space zoom and center the plan. */
private _restoreZoom(): void {
this._cancelCameraTransition(false);
const z = this._zoomBySpace[this._space] || 1;
this._zoom = z;
const stage = this._stageEl;
@@ -6255,6 +6384,7 @@ export class HouseplanCard extends LitElement {
}
private _stagePointerDown(ev: PointerEvent): void {
this._cancelCameraTransition(false);
if (this._vacFit) return; // no pan/swipe while fitting the robot map
// The shared secondary controller owns palette dismissal in window
// capture, including the matching synthetic click. Do not duplicate that
@@ -6450,7 +6580,7 @@ export class HouseplanCard extends LitElement {
// double tap (no movement) resets the zoom to 1:1
if (Math.abs(dx) + Math.abs(dy) < 8) {
const now = Date.now();
if (now - this._lastTap < 350) this._resetZoom();
if (now - this._lastTap < 350) this._resetZoom('double-tap');
this._lastTap = now;
}
// The lock is FINAL (audit DEV-1DA1-02). `_stagePointerMove` decided
+2 -2
View File
@@ -64,7 +64,7 @@ const cubicCoordinate = (t: number, p1: number, p2: number): number => {
return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t;
};
const ease = (p: number): number => {
export const easeTransitionProgress = (p: number): number => {
// Exact CSS cubic-bezier(0.2, 0.7, 0.2, 1): animation progress is the
// curve's x coordinate, so solve x(t) and then evaluate y(t). A bounded
// bisection is deterministic, monotonic and negligible beside one render.
@@ -126,7 +126,7 @@ export function interpolateModeVisualState(
to: ModeVisualState,
progress: number,
): ModeVisualState {
const p = ease(progress);
const p = easeTransitionProgress(progress);
const stageWidth = lerp(from.stageWidth, to.stageWidth, p);
const stageHeight = lerp(from.stageHeight, to.stageHeight, p);
// Screen scale interpolates logarithmically, so zoom speed is perceptually
+212
View File
@@ -0,0 +1,212 @@
import { easeTransitionProgress } from './mode-transition';
export interface CameraViewBox { x: number; y: number; w: number; h: number }
export interface CameraState {
zoom: number;
viewBox: CameraViewBox;
}
export type CameraTransitionReason = 'button' | 'wheel' | 'fit' | 'home' | 'double-tap';
export type CameraTransitionPhase = 'running' | 'settling';
export interface CameraTransitionState {
token: number;
phase: CameraTransitionPhase;
from: CameraState;
to: CameraState;
presented: CameraState;
startedAt: number;
duration: number;
reason: CameraTransitionReason;
}
export interface CameraTransitionHooks {
frame: (state: CameraTransitionState) => void;
settled: (state: CameraTransitionState) => void;
}
export interface CameraTransitionClock {
now: () => number;
requestFrame: (callback: FrameRequestCallback) => number | null;
cancelFrame: (handle: number) => void;
}
const browserClock: CameraTransitionClock = {
now: () => typeof performance !== 'undefined' ? performance.now() : Date.now(),
requestFrame: (callback) => typeof requestAnimationFrame === 'function'
? requestAnimationFrame(callback) : null,
cancelFrame: (handle) => {
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle);
},
};
const finite = (value: number): boolean => Number.isFinite(value);
export function validCameraState(state: CameraState | null | undefined): state is CameraState {
return !!state && finite(state.zoom) && state.zoom > 0
&& finite(state.viewBox.x) && finite(state.viewBox.y)
&& finite(state.viewBox.w) && state.viewBox.w > 0
&& finite(state.viewBox.h) && state.viewBox.h > 0;
}
export function cloneCameraState(state: CameraState): CameraState {
return { zoom: state.zoom, viewBox: { ...state.viewBox } };
}
export function sameCameraState(a: CameraState, b: CameraState): boolean {
return Math.abs(a.zoom - b.zoom) < 1e-9
&& Math.abs(a.viewBox.x - b.viewBox.x) < 1e-6
&& Math.abs(a.viewBox.y - b.viewBox.y) < 1e-6
&& Math.abs(a.viewBox.w - b.viewBox.w) < 1e-6
&& Math.abs(a.viewBox.h - b.viewBox.h) < 1e-6;
}
const lerp = (a: number, b: number, progress: number): number => a + (b - a) * progress;
const logLerp = (a: number, b: number, progress: number): number =>
Math.exp(lerp(Math.log(a), Math.log(b), progress));
export function interpolateCameraState(
from: CameraState,
to: CameraState,
progress: number,
): CameraState {
if (!validCameraState(from)) return cloneCameraState(to);
if (!validCameraState(to)) return cloneCameraState(from);
const p = easeTransitionProgress(progress);
if (p <= 0) return cloneCameraState(from);
if (p >= 1) return cloneCameraState(to);
const fromCenterX = from.viewBox.x + from.viewBox.w / 2;
const fromCenterY = from.viewBox.y + from.viewBox.h / 2;
const toCenterX = to.viewBox.x + to.viewBox.w / 2;
const toCenterY = to.viewBox.y + to.viewBox.h / 2;
const w = logLerp(from.viewBox.w, to.viewBox.w, p);
const h = logLerp(from.viewBox.h, to.viewBox.h, p);
const centerX = lerp(fromCenterX, toCenterX, p);
const centerY = lerp(fromCenterY, toCenterY, p);
return {
zoom: logLerp(from.zoom, to.zoom, p),
viewBox: { x: centerX - w / 2, y: centerY - h / 2, w, h },
};
}
/** Build an unclamped target which leaves the presented world point under the anchor. */
export function cameraTargetAtAnchor(
from: CameraState,
targetZoom: number,
fit: CameraViewBox,
stageWidth: number,
stageHeight: number,
anchorX: number,
anchorY: number,
): CameraState | null {
if (!validCameraState(from) || !validCameraState({ zoom: targetZoom, viewBox: fit })
|| !finite(stageWidth) || stageWidth <= 0 || !finite(stageHeight) || stageHeight <= 0
|| !finite(anchorX) || !finite(anchorY)) return null;
const xRatio = anchorX / stageWidth;
const yRatio = anchorY / stageHeight;
const worldX = from.viewBox.x + xRatio * from.viewBox.w;
const worldY = from.viewBox.y + yRatio * from.viewBox.h;
const w = fit.w / targetZoom;
const h = fit.h / targetZoom;
return {
zoom: targetZoom,
viewBox: { x: worldX - xRatio * w, y: worldY - yRatio * h, w, h },
};
}
/** One token/RAF owner for camera-only transitions inside a settled UI mode. */
export class CameraTransitionController {
private _token = 0;
private _raf: number | null = null;
private _state: CameraTransitionState | null = null;
public constructor(
private readonly _hooks: CameraTransitionHooks,
private readonly _clock: CameraTransitionClock = browserClock,
) {}
public get state(): CameraTransitionState | null { return this._state; }
public get active(): boolean { return this._state?.phase === 'running'; }
public get presented(): CameraState | null { return this._state?.presented || null; }
public get target(): CameraState | null { return this._state?.to || null; }
public start(
fromInput: CameraState,
toInput: CameraState,
reason: CameraTransitionReason,
duration: number,
): number {
this.cancel(false);
const token = ++this._token;
const from = cloneCameraState(fromInput);
const to = cloneCameraState(toInput);
const canAnimate = duration > 0 && validCameraState(from) && validCameraState(to);
const now = this._clock.now();
const state: CameraTransitionState = {
token,
phase: canAnimate ? 'running' : 'settling',
from,
to,
presented: canAnimate ? cloneCameraState(from) : cloneCameraState(to),
startedAt: now,
duration: canAnimate ? duration : 0,
reason,
};
this._state = state;
this._hooks.frame(state);
if (!canAnimate) {
this._settle(token);
return token;
}
const tick = (time: number): void => {
const current = this._state;
if (!current || current.token !== token) return;
const raw = Math.max(0, Math.min(1, (time - current.startedAt) / current.duration));
current.presented = raw >= 1
? cloneCameraState(current.to)
: interpolateCameraState(current.from, current.to, raw);
current.phase = raw >= 1 ? 'settling' : 'running';
this._hooks.frame(current);
if (raw >= 1) {
this._raf = null;
this._settle(token);
return;
}
this._raf = this._clock.requestFrame(tick);
if (this._raf === null) this._settle(token);
};
this._raf = this._clock.requestFrame(tick);
if (this._raf === null) {
state.phase = 'settling';
state.presented = cloneCameraState(to);
this._hooks.frame(state);
this._settle(token);
}
return token;
}
public cancel(commitTarget = true): void {
if (this._raf !== null) this._clock.cancelFrame(this._raf);
this._raf = null;
const state = this._state;
this._state = null;
this._token++;
if (!state || !commitTarget) return;
state.phase = 'settling';
state.presented = cloneCameraState(state.to);
this._hooks.frame(state);
this._hooks.settled(state);
}
public dispose(): void { this.cancel(false); }
private _settle(token: number): void {
const state = this._state;
if (!state || state.token !== token) return;
state.phase = 'settling';
state.presented = cloneCameraState(state.to);
this._hooks.settled(state);
if (this._state?.token === token) this._state = null;
}
}
+138
View File
@@ -0,0 +1,138 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
CameraTransitionController,
cameraTargetAtAnchor,
interpolateCameraState,
sameCameraState,
validCameraState,
} from '../test-build/viewport-transition.js';
const camera = (zoom, x, y, w, h) => ({ zoom, viewBox: { x, y, w, h } });
function fakeClock() {
let now = 0;
let sequence = 0;
const callbacks = new Map();
return {
clock: {
now: () => now,
requestFrame: (callback) => { callbacks.set(++sequence, callback); return sequence; },
cancelFrame: (handle) => callbacks.delete(handle),
},
callbacks,
step(time) {
now = time;
const entry = callbacks.entries().next().value;
assert.ok(entry, 'one animation frame is scheduled');
callbacks.delete(entry[0]);
entry[1](time);
},
};
}
test('camera interpolation uses perceptual zoom, linear center and exact endpoints', () => {
const from = camera(1, 0, 0, 800, 600);
const to = camera(4, 500, 350, 200, 150);
assert.deepEqual(interpolateCameraState(from, to, 0), from);
assert.deepEqual(interpolateCameraState(from, to, 1), to);
const mid = interpolateCameraState(from, to, 0.5);
assert.ok(mid.zoom > 1 && mid.zoom < 4);
assert.ok(mid.viewBox.w < 800 && mid.viewBox.w > 200);
assert.equal(mid.viewBox.w / mid.viewBox.h, 4 / 3);
assert.ok(mid.viewBox.x > from.viewBox.x && mid.viewBox.x < to.viewBox.x);
});
test('anchor target preserves the presented world point without clamp', () => {
const from = camera(1, 100, 40, 800, 600);
const fit = { x: 0, y: 0, w: 800, h: 600 };
const target = cameraTargetAtAnchor(from, 2, fit, 800, 600, 120, 450);
assert.ok(target);
const before = [
from.viewBox.x + 120 / 800 * from.viewBox.w,
from.viewBox.y + 450 / 600 * from.viewBox.h,
];
const after = [
target.viewBox.x + 120 / 800 * target.viewBox.w,
target.viewBox.y + 450 / 600 * target.viewBox.h,
];
assert.ok(Math.abs(before[0] - after[0]) < 1e-9);
assert.ok(Math.abs(before[1] - after[1]) < 1e-9);
});
test('controller settles exactly and owns one RAF', () => {
const fake = fakeClock();
const frames = [];
const settled = [];
const controller = new CameraTransitionController({
frame: (state) => frames.push(state.presented),
settled: (state) => settled.push(state.presented),
}, fake.clock);
const from = camera(1, 0, 0, 800, 600);
const to = camera(2, 200, 150, 400, 300);
controller.start(from, to, 'button', 100);
assert.equal(controller.active, true);
assert.equal(fake.callbacks.size, 1);
fake.step(50);
assert.equal(fake.callbacks.size, 1);
assert.ok(frames.at(-1).zoom > 1 && frames.at(-1).zoom < 2);
fake.step(100);
assert.deepEqual(settled, [to]);
assert.equal(controller.active, false);
assert.equal(fake.callbacks.size, 0);
});
test('retarget starts from presented state and cancels the obsolete RAF', () => {
const fake = fakeClock();
const frames = [];
const controller = new CameraTransitionController({
frame: (state) => frames.push(state.presented),
settled: () => {},
}, fake.clock);
const from = camera(1, 0, 0, 800, 600);
const first = camera(2, 200, 150, 400, 300);
const reversed = camera(0.75, -133, -100, 1066, 800);
controller.start(from, first, 'wheel', 100);
fake.step(40);
const presented = controller.presented;
assert.ok(presented);
controller.start(presented, reversed, 'wheel', 100);
assert.deepEqual(frames.at(-1), presented);
assert.equal(fake.callbacks.size, 1);
assert.deepEqual(controller.target, reversed);
});
test('cancel policies either freeze or commit one exact target', () => {
const fake = fakeClock();
const settled = [];
const controller = new CameraTransitionController({
frame: () => {},
settled: (state) => settled.push(state.presented),
}, fake.clock);
const from = camera(1, 0, 0, 800, 600);
const to = camera(2, 200, 150, 400, 300);
controller.start(from, to, 'fit', 100);
controller.cancel(false);
assert.equal(settled.length, 0);
assert.equal(fake.callbacks.size, 0);
controller.start(from, to, 'fit', 100);
controller.cancel(true);
assert.deepEqual(settled, [to]);
assert.equal(fake.callbacks.size, 0);
});
test('invalid input and missing RAF settle safely without a loop', () => {
const from = camera(1, 0, 0, 800, 600);
const to = camera(2, 200, 150, 400, 300);
assert.equal(validCameraState(camera(0, 0, 0, 0, 0)), false);
assert.equal(cameraTargetAtAnchor(from, 2, to.viewBox, 0, 600, 10, 10), null);
const settled = [];
const controller = new CameraTransitionController({
frame: () => {},
settled: (state) => settled.push(state.presented),
}, { now: () => 0, requestFrame: () => null, cancelFrame: () => {} });
controller.start(from, to, 'button', 180);
assert.deepEqual(settled, [to]);
assert.equal(controller.active, false);
assert.equal(sameCameraState(to, { zoom: 2, viewBox: { ...to.viewBox } }), true);
});
+1 -1
View File
@@ -25,7 +25,7 @@
"src/space-geometry.ts", "src/junction-limits.ts",
"src/space-order.ts", "src/card-editor-validation.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
"src/visual-continuity.ts", "src/mode-transition.ts", "src/editor-runtime-loader.ts", "src/pointer-modality.ts",
"src/visual-continuity.ts", "src/mode-transition.ts", "src/viewport-transition.ts", "src/editor-runtime-loader.ts", "src/pointer-modality.ts",
"src/render-device-snapshot.ts",
"src/command-stack.ts", "src/device-position-history.ts",
"src/coordinate-canonicalization.ts",