diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 899cd21..23d8d3b 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,16 +1,14 @@ -d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example -a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore +d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs 0696b5112a35943786aa4dbe0547b745d158309795a8e8967946eed07cb0d1e7 ./Cargo.lock 000dd8d1a535f357667cf812f817a2abbc72eb79fe062ae2dcf8fef65d6b7491 ./Cargo.toml -19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -c353091e7695cd19f77bdcad80b41438710a7315c5f2696016dc482000023a21 ./README.md -d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs +ceafb0ec649a7a7ea0c469501dab144743306150c15869f3a084bab3ce47f51d ./data/gree-controller.db 9596e50efb3ce09afd823d2507c814cfbffe812a622b45c9e0d53526e8cd996a ./docs/API.md -1b522e1b61643733accf0a45b8b862cfb96e1468343c9819600e263fe67a2b96 ./docs/CODE_AUDIT_0.12.0.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md 933f560266bd309be0b1eaaa943e86874646e0b9ada5e805bf47a7a62f7063b8 ./docs/openapi.json -a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md -f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py +d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env +d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example +a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore +d48b8a8fbd32b483e5aa176111ec821450a338438780f73fde11e11ac2ac4271 ./gree-controller.zip 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py 71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py 5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py @@ -18,6 +16,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assista ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png +f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png fec5ff50552f331bc0f994d9980059f1b1c309a57419e20f785c04d93dcd14f9 ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py @@ -27,14 +26,12 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista 6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./home-assistant/generated/gree_controller_entities.example.json -253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md +b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md 810bf9a9483277cbde14454b6b88dbfe79b873a6f34737031a7e10d23fda9299 ./lang/en.json ad48d11875aa041c0f593aa6e322f8d953aef137d4df4c369c5f82eec5aebd11 ./lang/pl.json +253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md +19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py -daf25cf3faf39865033e57cc7f79a34b0e0dbca174a37d349611f14d77143e15 ./mock/index.html -efe0fb67500857bfcebb2dcfba77fdc551e5c2e32d2ffff4b092c19528dc679e ./mock/mock.js -6abcb2d3607aa76d763f4ccaa17523f6bc69faaf19e89f0b306559750ff2f066 ./mock/styles.css -d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./mock/theme-init.js b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json 1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json c5276fecc6a33c5de912e5a73b0a829d4e18c18d57b9c0ee49179ef96f133fc7 ./presets/dual_threshold_control.json @@ -72,8 +69,8 @@ fffa18bd989a0dcc1c5458975522517d40af4af40cc1ca30640e79d3db711053 ./presets/unoc ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json +c353091e7695cd19f77bdcad80b41438710a7315c5f2696016dc482000023a21 ./README.md 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh -bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh @@ -82,13 +79,12 @@ cd2eb8c851ab3cacd00efcb5cdbd404f03279e75126c46f025e132ba1e9d4ce1 ./scripts/api_ e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh +bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -0790b7b388467d9e75b621188cca473e22d73e01bcfa74e92fa23f92b0695855 ./scripts/verify_flow_logic.py -0d1471ef9a384919fff623086027a9edd58a844524588a6860d7b2016667a319 ./src/api.rs -d090e676e55fd9de1cb46accfec841b93e3dae81006f6e18faa3466639b2e51e ./src/api/assets.rs +04084aac0c2af07a9bb2d0d7d4499efaed47944aefff526f32c73a557a491224 ./src/api/assets.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs @@ -100,16 +96,16 @@ d090e676e55fd9de1cb46accfec841b93e3dae81006f6e18faa3466639b2e51e ./src/api/asse 641f17dc5b107e6f13949ada8d05e8fdf878d0e44666ddc72aea71a57c70df06 ./src/api/history.rs e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs 6139e17a08e35d0fcd07ee5625983c602d758ae77452c924f4b4cc05580e9ee8 ./src/api/integrations.rs -ef3b245ffafd73e0084da9b291e9c7d98c014206a42a914e33ed6ca73aad070c ./src/api/middleware.rs +6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs 45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs 744ce18aa9e0227c6cdfb6980063d0d738d74c5865d45c20fd20026d4742bb3e ./src/api/public_settings.rs +e86df48db04868bd65bb7815fb9205fec8b29b5212cfb84af74990e0eb380120 ./src/api.rs 5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs 9d157e1901081c077e32430d5d4d7f328ffb4933996579f584ce2959d5d10f47 ./src/api/settings.rs 20dddb2a07f4a647226783b66f6f3032c6a1ba2910134408d79cf362f909299c ./src/api/system.rs 63f02760d0e2cfd59788b5b1cdf4317c685aab1b5a0a5665d52b50f793ce5c74 ./src/api/websocket.rs c742634b5c8001b8e31ce313a1114abb1f44f18186a86860de4b6b1c01a4832f ./src/api/zones.rs f06014454d5062b1be17ac50d875a42c02fc778b59702ec455611265ab7de934 ./src/config.rs -e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs 6c278fc5728124ab2f53ac7789a733942a0462f397144a231cc878c8cfcf7426 ./src/db/climate.rs be4082b142fe16ba40c5978b287b7ef808e67c13edbd8f2d6637ccad974b8847 ./src/db/configuration.rs bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_devices.rs @@ -117,10 +113,10 @@ bc10f1a66ddcaacbb08b096a02998482da547024dd0ba3144d00f993b4351cb0 ./src/db/core_ eb07d94b58bcc8ea17b5b086cc88c165a35666f84c3d9a1d74f72dc30b979829 ./src/db/events_tokens.rs bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs 5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs +e0340b0e5945d5a38a1054e549c819566d5c1ef6034ab0e4d44c68a3b9a249cf ./src/db.rs c0a7fa3eac198737d7261ad00c542a614e6d7291f6581af8e7f7aef9dec1a049 ./src/db/schedules_automations.rs 1a209dc6d8df864778bb03c980f309a2ec86fc62df274880fbb95eff8470174e ./src/db/tests.rs f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs -35f7ed5fb42199afbb331dd6cfb992815a77a3fea346edc176cd4a2b0fcab58f ./src/engine.rs ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs 88f85acf692e12c7061e039dd7c54f101fe0b014c4a3d799150ad92eea9e2894 ./src/engine/commands.rs 10ab2d7abc0f149936393129459dc56e85d8fca51d96da71c742cb96e86a6f08 ./src/engine/control_plan.rs @@ -130,6 +126,7 @@ f0c8f3384b6174771aeb5640ee1eec87f3d6d9ff79a4fbd6a35554a4eec00cfa ./src/engine/h 41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs 6280a7f5059d9737987bf2d72b19a26b3a6709ce578af1108c4215f3f47d7763 ./src/engine/ownership.rs 9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs +35f7ed5fb42199afbb331dd6cfb992815a77a3fea346edc176cd4a2b0fcab58f ./src/engine.rs b0e33539dc7ef52dfa4cad500a96b26bf124b2bc195c9f7965fe03eb71de6621 ./src/engine/runtime.rs 7c4ad4de24833504c2d2c692f5a154977f0d6ddba2bd82f16235806a85a8d64e ./src/engine/schedules.rs de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/targets.rs @@ -140,12 +137,11 @@ de80d9ff69f1c843fdac23fdc3e3a350fc1082a186261b3a38617af189132356 ./src/engine/t 56d9af59eb700ac6363d26db0ffbea777270cdbbe8e5b7eb982bff9f02539eee ./src/engine/zone_control.rs 2276aca42bca712a55de9b6c5dbe64639675c84befa85a446a7d34613596ed7e ./src/error.rs 2731f629f22a9d4fdb831ea3b283f43900b190f7e6a08fdab5b8fbb2c288547b ./src/home_assistant.rs -342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs 1769e2a96e336bf39b28ae9de02a5d942350afa2bb9702df7603fabe9d7fcba7 ./src/influxdb/query.rs +342dcb54caf22d2282a3f3f2297f54bd413b5242c17756276ea74994e88e28fb ./src/influxdb.rs 29dc8ed02dfa1f6419bf4aedfb99c805b25c8a2e772efef20681c750570928cf ./src/influxdb/write.rs df4b6550fea08e070488ca9abafb8c3794d13b21715a7160d255839e662768e2 ./src/main.rs -23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs 3c3f344fe42cdde980b13dbec6e92ed0e2f5946cba8ac19b1a668846b48e7c81 ./src/models/control_plan.rs 9cd1904a0f822c557f6ad4576cdb5f96d9668d04e79ea9783885c9f39519752e ./src/models/defaults.rs @@ -153,13 +149,13 @@ f935d35b4a409f12774209f3fa2ffe60f579a8bc1722badbb27d4766efd6368e ./src/models/d aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs cf63af00d98410a1452f205ce00b8a6c5f0f3525b994cba3e971d631db288396 ./src/models/history.rs ea216f24f0f385903324f15a1164a0fc508adbdcc3c30d16383ea2e835c53e6e ./src/models/integrations.rs +23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs 8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs 887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs 7d798228a8d4d4e60a112167c001535f4b8a5fa32ec7a33554c73125c42256c2 ./src/models/zone.rs 2ccac88080ef3ca8546facad5167cbaadc080bdee3f5d9100c8fabb0fc3ae49d ./src/notifications.rs cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs -28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs 7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol/gree/commands.rs 4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs @@ -167,14 +163,15 @@ d4df52f0202eb32290e68dc945c4b0b44f3be8aa90bd57fd116eb20285960616 ./src/protocol 0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs de37931303fc92a9399e6edc17dfabd6870a38cd2ddb27257427c76768a66ec2 ./src/protocol/gree/polling.rs +28969da06a454ddd1ebea5fa606e8446a83dfd21e21352767c189013c508cb90 ./src/protocol/gree.rs c868422c02ba457df21ae92acbd00698c243855ddd1d4e95ac2166796eba9a44 ./src/protocol/gree/tests.rs 3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs 923bb4b2d342d6c1870474e43f33b0aba16dc88590c561d765b874412e83df1f ./src/protocol/mod.rs -830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs 3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs 2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs +830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs df2f13c98b9d7f84916dcbf9e01a479a6f392f888413fe22bae586526747f704 ./src/state.rs @@ -182,7 +179,6 @@ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg d0388bf22f34ae28fb63074b0bc15c087a3be298bb702d006d4814ebb786b36e ./web/index.html -9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 8fb20fdba11df68feb0b096bb5b4c8f936db974aed5d345a0d01455054f31f95 ./web/js/bootstrap.js badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js af92b2140f6248bc636d7c75e37a9460f4128ba59adf1b7842d25244a607555c ./web/js/core.js @@ -194,11 +190,12 @@ f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js +9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 8505ff605de9bcb11f79a0d01a6c5c15f0482d3f65a5e94cc510308a36f558cc ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js -427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js 9dd9fbcc5f00584562bae6a840189495f830d1086d9679763dbad6e9c18c4fdb ./web/js/settings.js +427376a35832cb3644206c6e89e1654bd807eec78352f80bd0dd43f74b4ec810 ./web/js/settings-ui.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -9976b7dd54d0075c3bc153f520740d25b7870042c32020b96ac8547669a1f148 ./web/styles.css +5c939ab91aa28ba386aefef6ba5f41b0c73fae3936f6e915fbc39e2336859605 ./web/styles.css ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/docs/CODE_AUDIT_0.12.0.md b/docs/CODE_AUDIT_0.12.0.md deleted file mode 100644 index ec249e9..0000000 --- a/docs/CODE_AUDIT_0.12.0.md +++ /dev/null @@ -1,75 +0,0 @@ -# Code audit — 0.12.0 - -Scope: settings/configuration API, frontend consumers, configuration import/export, and the largest control handlers touched by the API/runtime ownership changes. - -## Breaking API redesign - -0.12.0 intentionally removes the previous monolithic/compatibility routes. There are no aliases. - -| Removed | Replacement | -| --- | --- | -| `GET/PUT /api/settings` | functional `/api/settings/*` resources | -| `GET/PUT /api/debug` | `GET/PUT /api/settings/debug` | -| `PUT /api/events/retention` | `GET/PUT /api/settings/history` | -| `GET /api/settings/export` | `GET /api/configuration/export` | -| `POST /api/settings/import` | `POST /api/configuration/import` | - -Functional settings resources: - -- `/api/settings/application` -- `/api/settings/gree` -- `/api/settings/history` -- `/api/settings/influxdb` -- `/api/settings/notifications` -- `/api/settings/night` -- `/api/settings/home-assistant` -- `/api/settings/debug` - -The bootstrap payload no longer exposes runtime settings. The Web UI fetches each settings resource explicitly. - -## Correctness and security findings addressed - -- Settings writes no longer require a full `RuntimeSettings` document, so changing one section cannot overwrite unrelated settings. -- Integration secrets are never returned by settings GET endpoints. Secret-bearing PUT fields are optional: omitted/null preserves the stored value; an explicit empty string clears it. -- Configuration backup/restore was separated from runtime settings into `/api/configuration/*`. -- Configuration format was raised to version `3`; imports accept only version `3`. -- Format-v3 imports reject unsupported control strategies; `0.12.0` accepts only `setpoint`. -- Import validates and normalizes the same GREE/history/InfluxDB/notification/night/Home Assistant constraints used by functional settings endpoints. -- Shared Flow inputs in an imported backup are validated against devices/zones/groups from that backup, not against the configuration currently stored in the database. -- Obsolete `house_power_enabled` and `master_power_enabled` compatibility state was removed from runtime/settings/group responses. -- Settings WebSocket broadcasts are section-specific (`settings.
.updated`) instead of the former whole-settings event. - -## Refactoring performed - -Approximate function lengths are shown to make the audit measurable; line count is only a hotspot signal, not a quality metric by itself. - -| Area | Before | After | -| --- | ---: | ---: | -| `engine::control_group` | 292 | 137 | -| `api::apply_zone_control_patch` | 415 | 214 | -| `engine::control_zones` | 666 | 413 | -| monolithic `api::update_settings` | 83 | removed | -| monolithic configuration validator | 155 | removed/split | -| monolithic settings import handler | 98 | removed/split | - -New smaller responsibilities include: - -- GREE settings normalization and compressor-runtime cleanup; -- InfluxDB and notification update/secret merge helpers; -- Home Assistant alias/entity/input validation; -- configuration header, IDs, flows, schedules and automation validation; -- configuration runtime sanitation, resource locking, detached-device shutdown and reconciliation; -- group patch validation, climate changes, power changes and ownership updates; -- Temporary Quick Thermostat start, target and finish resolution; -- control-cycle outdoor sensor resolution, parallel room sensor reads, temperature-source selection and pre-control state handling. - -## Remaining hotspots - -`control_zones` remains the largest function because it is the ordered thermostat-cycle orchestrator and contains compressor/setpoint command planning. It was reduced substantially without changing lock ordering or splitting the core state machine across opaque abstractions. Flow evaluation/compiler functions remain domain-heavy but were not changed as part of this API release because they are independent of the settings redesign and already have dedicated logical verification. - -## Frontend/documentation - -- Web UI settings loading/saving uses only the new functional endpoints. -- Configuration import/export uses `/api/configuration/*`. -- Realtime settings handling uses section-specific events and serializes WebSocket message processing so reconnect bootstrap cannot race later updates. -- `docs/API.md`, `docs/openapi.json`, README and smoke/static Flow checks were updated for 0.12.0. diff --git a/home-assistant/README.md b/home-assistant/README.md index d5baf73..378bf45 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -10,7 +10,7 @@ It creates HA entities for physical units, thermostat zones, whole-house control The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing. -Version 0.5.0 additionally exposes the controller's automation plan: +Automation plan: - `sensor` for the whole-house plan (house mode, upcoming events and active rules), - one `sensor` per thermostat zone with current demand, active preset/schedule and upcoming transitions, @@ -118,13 +118,13 @@ Bedroom -> GREE Bedroom + sensor.bedroom_temperature ``` The recommended `combined` strategy keeps the GREE sensor as the primary input and uses the room sensor as a configurable supporting measurement (40% weight by default). A zone may also select the room sensor as its preferred source. If HA or that entity becomes unavailable, the controller falls back to the corresponding GREE unit, so local control and schedules continue to run. -## Zone climate and optional unit features (0.5.4) +## Zone climate and optional unit features Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility. -From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch reports the configured zone state independently from group control. Turning a group OFF powers its member units down and releases group ownership. Members are not blocked merely because the group remains OFF: an individual thermostat can be turned back on independently. Turning the group ON clears that scoped OFF state and immediately resumes group thermostat arbitration. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone. From 0.7.12, every local OFF creates a fresh backend-owned 15-minute hand-back deadline exposed as `local_thermostat_resume_at`. A direct/pilot takeover suspends expiry of that local timer; if the unit is returned to the previous OFF state, the backend re-arms a fresh 15-minute countdown from that moment. The separate zone Enabled switch remains the technical availability switch. +Each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch reports the configured zone state independently from group control. Turning a group OFF powers its member units down and releases group ownership. Members are not blocked merely because the group remains OFF: an individual thermostat can be turned back on independently. Turning the group ON clears that scoped OFF state and immediately resumes group thermostat arbitration. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone. From 0.7.12, every local OFF creates a fresh backend-owned 15-minute hand-back deadline exposed as `local_thermostat_resume_at`. A direct/pilot takeover suspends expiry of that local timer; if the unit is returned to the previous OFF state, the backend re-arms a fresh 15-minute countdown from that moment. The separate zone Enabled switch remains the technical availability switch. -From version 0.6.4, zone climate entities use `climate._thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning. +Zone climate entities use `climate._thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning. When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created. @@ -140,7 +140,7 @@ The integration also exposes three controller-level entities on the **GREE Contr These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints. -## Climate groups (0.7.3) +## Climate groups Every group configured in **GREE Controller -> Groups** is published as a separate Home Assistant device. A group does not receive a fake common target temperature because its member zones can legitimately use different profile temperatures. Instead, the group device exposes the controls that exactly match the controller model: @@ -153,6 +153,6 @@ If member zones have been changed individually and no longer share one mode or p After upgrading the custom integration, restart Home Assistant or reload **Settings -> Devices & services -> GREE Controller**. Also reload the integration after adding/removing/renaming groups so new group devices/entities are created. -## Command state stability (0.7.6) +## Command state stability Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. From version 0.7.9 the same guard also covers per-zone enable, HVAC mode, profile and target-temperature commands. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state. diff --git a/mock/index.html b/mock/index.html deleted file mode 100644 index 5526068..0000000 --- a/mock/index.html +++ /dev/null @@ -1,1308 +0,0 @@ - - - - - - - - - - - - - GREE Controller - - - -
-
-
GREE Controller
-
-
0/0
-
-
- - - -
-
- - - -
- -
- - -
- -
-
- - -
-
-
Home -

Comfort under control

-

Loading devices…

-
-
-
-
--°C
-
-
-
-
-
Global power -

All air conditioners

-

Bulk power action for all units. OFF leaves thermostats locally off - until you explicitly turn a thermostat, group or the whole house back on.

-
-
-
-
-
-
-
-
-
Control plan -

What happens next

-
-
-
-
-
- - - - -
- -
-
-
LAN network -

Devices

-
-
-
-
-
- -
-
-
Automation -

Zones

-
-
-
-

Zones define thermostat logic and sensor configuration. Use Quick - thermostats on the Dashboard for current temperature changes and presets.

-
-
- -
-
-
Group control -

Groups

-
-
-

Combine thermostat zones and control only that set. Turning a group - on activates its thermostats; turning it off powers its units down and releases group ownership.

-
-
- -
-
-
Visual logic -

Flow

-
-
- -
-
One place for schedules and automations -

Build the logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.

-
- Flow = source of truth -
-
-
-
-
-
- -
-
-
Calendar -

Schedules

-
-
- -
-
-

Quick schedule setup

-

Choose a zone and a ready-made daily rhythm. You - can edit every generated entry afterwards.

-
- -
- - - - - -
-
-
-
- -
-
-
Rules -

Automations

-
-
- -
Use thermostat presets for daily - comfort -

Schedules and zone presets handle normal heating/cooling automatically. - Keep automations for special exceptions so they do not fight the thermostat.

-
-
-
- -
-
-
Simulator -

Automation simulator

-
-
-
-

Live preview of the thermostat and automation logic. The flow - board shows how sensor readings, schedules, night mode and thermostat decisions become commands sent to each - GREE unit.

-
-
-
-
-
Live flow -

Automation flow board

-
-
InputLogicAction
-
-
-
-
-
-
-
-
Timeline -

What will happen next

-
-
-
-
-
-
-
-
-
Rules -

Automation rules

-
-
-
-
-
-
-
- -
-
-
Measurements -

Climate history

-
-
-
- - - - - -
-
-
-
- - -
-
-
-
-
-
- - -
-
-
Comfort -

Night mode

-
-
-

Configure quieter thermostat behavior for sleeping hours without - mixing it with application settings.

-
-
-
-
-

Night mode

-

During selected hours the thermostat limits fan speed and can - request Quiet on supported units.

-
-
-
- - - - - - -
-
-
Save night-mode changes.
-
-
- -
-
-
Integration -

Home Assistant / Sensors

-
-
-

Connection, temperature sensors, friendly aliases and access - tokens for the Home Assistant integration.

-
-
-

Połączenie z Home Assistant

Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.

-
- - - -

Use only for a trusted local Home Assistant server.

-
-
-
- -
-

Źródła temperatury sterowania

Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.

-
- -

This entity is used by the Home Assistant connection test when no other entity is specified. It is not automatically used as a zone temperature source.

- - -

If the HA sensor is not updated within this time, the reading is treated as stale.

- -
-
- -
-

Wspólne wejścia Flow

Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow. Czujniki HA dodane tylko tutaj nie są zapisywane do metryk.

-
-
- -
-

Sensor aliases

Badges show whether an entity is collected as metrics, used by Flow, or both.

-
-
-
-
-
- -
-

Home Assistant integration access

Create a controller token and paste it into the GREE Controller integration in Home Assistant.

-
-
-
Save Home Assistant and sensor changes.
-
-
- -
-
-
System -

Settings

-
-
-
- - -
-
-
-
-
-
-

Application runtime

-

Application-wide behavior that does not change GREE - transport or discovery.

-
-
-
- -

When enabled, a prominent - warning is shown throughout the application.

-
-
-
-
-
-

Notifications

-

Send important events or only problems/anomalies.

-
-
-
- - - -
- - - - - -
- Alert types -

Choose exactly which alerts may be - delivered. Thresholds above still define when applicable alerts are raised.

-
- - - - - - - - -
-
-
-
-
-
-
-
-

Metrics and logs

-

SQLite keeps recent data locally and compacts older samples to - the resolution used by charts.

-
-
-
- - - -
-
-
-
-
-

Long-term InfluxDB history

-

Optional archive for older history. InfluxDB 1.x and 2.x are - supported.

-
-
-
- - - - -
- - - -
-
- - - -
-
-
-
-
-
-

On-screen debug

-

Live diagnostics displayed on every application page.

-
-
-
- - -
-
-
-
-
-

Configuration backup

-

Export/import application configuration. - Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API - access tokens are not included.

-
-
-
-
-
-
- -
Save changes made in the blocks - above.
-
-
- -
-
-
Diagnostics -

Events

-
-
-
-
Log retentionOld event - rows are removed automatically during maintenance.
-
-
-
-
- - - - - -
Blocks

Add block

- -
-
- - -

Actions

- -
- - - - -
-
Wspólne dane

Wspólne wejście Flow

- - - -
- -
-
-
- - -
-
Flow library

Templates

-

Ready-made layouts create an editable graph using existing zones, devices and groups.

-
-
-
-
-
-
- -
-
-
- - -
-
Flow diagnostics

Dry-run

-
-

Dry-run does not change thermostat, device, group, schedule or automation state.

- -
Sensor overrides

Leave blank to use the current application or Home Assistant state.

-
-
-
-
-
- - - - -
-

More

-
- -
- - -
-
-

Add device

-
- - - -
- - -
-
-
- - - -
-
-

Discover GREE devices

-
-

Auto searches both AES-ECB and AES-GCM devices. Multiple passes - improve discovery when several Wi-Fi modules answer the same broadcast.

- -
-
-
-
- - -
-
-

Name discovered devices

-
-

Give each new unit a friendly room name. The technical - model and MAC remain available in diagnostics.

-
-
-
-
- - -
- -
-

Name / protocol

-
- - -

Changing protocol clears the saved device key and - performs a new bind on the next request.

-
-
-
- - -
- -
-

Zone

-
- - -
- -

If this zone should use the global Heating/Cooling mode - selected in GREE Controller, leave “Follow global mode” unchanged. Cooling only / Heating only forces a fixed - mode for this zone.

-
- Comfort / sleep / away temperatures -
Cooling
-
Heating
-
-
-
-
- -
- -
-

The room sensor is assigned only to this zone. If it becomes - unavailable, the controller falls back to the GREE sensor.

-
-
- - - -
-
-
- - -
-
-
Compressor protection -

Command queue

-
-
-

Commands waiting for the compressor protection window to end. - You can cancel one task or clear the whole queue.

-
Waiting0
-
-
-
-
- - -
- -
-
Szybki termostat -

Włącz czasowo

-
-
- - - - - - - - - - -
- -
-
- - - - - - -
-
-
- - -
- -
-

Group

-
- -
- Group members -
-
-

Groups are built from thermostat zones. ON takes control of - members; OFF powers their units down without leaving a group ownership lock.

-
-
-
- - -
- -
-

Schedule

-
- - -
- Weekdays -
-
-
- - - -
-
-
- - -
- -
-

Automation

-
-
- - - -
-
-

Action

- - - -
- -
- - -
-
-
- - -
-
-

Token created

-
-

Copy this token now. It will not be shown again.

- -
-
-
- - -
-
-
GREE ControllerAuthentication required
-
- - -
-
- - -
- - - - \ No newline at end of file diff --git a/mock/mock.js b/mock/mock.js deleted file mode 100644 index fa8a656..0000000 --- a/mock/mock.js +++ /dev/null @@ -1,36 +0,0 @@ -document.addEventListener('DOMContentLoaded',()=>{ - const q=new URLSearchParams(location.search); - document.documentElement.dataset.theme='dark'; - const view=q.get('view')||'dashboard'; - document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.dataset.view===view)); - if(view==='dashboard'){ - document.querySelector('#summaryText').textContent='3 urządzenia online · 2 aktywne termostaty'; - document.querySelector('#metrics').innerHTML='Online 3/3Aktywne 2'; - document.querySelector('#heroTemperature').innerHTML='21.4°C'; - document.querySelector('#houseClimate').innerHTML='

Tryb domu

Wspólne ustawienia dla stref.

Na zewnątrz 12.8°C
'; - document.querySelector('#controlPlan').innerHTML='

Salon

Utrzymuj 21.5°C do 22:30

Aktywne

Sypialnia

Tryb nocny od 22:30

za 2h
'; - } - if(view==='simulation'){ - document.querySelector('#simulationTarget').innerHTML=''; - document.querySelector('#simulationSummary').innerHTML='
Tryb domuGrzanieStrategia: temperatura zadana
Tryb nocnyWyłączony22:00 → 06:00
Na zewnątrz12.8°CAktualizacja 08:30
Aktywne strefy21 strefa zgłasza zapotrzebowanie
'; - const board=document.querySelector('#simulationFlowBoard'); board.style.width='1325px'; board.style.height='390px'; - board.innerHTML='
SalonGREE Salon
Temperatura

Salon

20.8°C

Home Assistant

Termostat

Salon

21.5°C

Komfort

Decyzja

Grzanie

Potrzeba ciepła

Poniżej celu

Polecenie

GREE Salon

22.0°C · Auto

Quiet: wył.

'; - document.querySelector('#simulationTimeline').innerHTML='
Salon

Komfort · 21.5°C · GREE Salon

Cały dom

Tryb nocny

Sypialnia

Komfort · 20.5°C · GREE Sypialnia

'; - document.querySelector('#simulationRules').innerHTML='
Automatyzacja

Otwarte okno

Stan

Urządzenie: GREE Salon

Wyłącz termostat, gdy okno pozostaje otwarte.
Ostatnio: 07:12Gotowa: teraz
Automatyzacja

Tani prąd

Próg

Urządzenie: GREE Sypialnia

Podnieś temperaturę docelową o 0.5°C.
Ostatnio: wczorajGotowa: teraz
'; - } - if(view==='flows'){ - document.querySelector('#flowListCount').textContent='3 flow'; - document.querySelector('#flowList').innerHTML=`

Okno w salonie

Wyłącza grzanie, gdy okno jest otwarte.

Bloki5
Harmonogramy0
Automatyzacje1

Tani prąd

Podbija temperaturę przy niskiej cenie energii.

Bloki7
Harmonogramy1
Automatyzacje2
`; - } - if(q.get('editor')==='1'){ - document.querySelector('#flowEditor').hidden=false; document.body.classList.add('flow-editor-open'); - document.querySelector('#flowName').hidden=true; document.querySelector('#flowNameView').hidden=false; document.querySelector('#flowNameText').textContent='Okno w salonie'; - document.querySelector('#flowCompileStatus').textContent='0 harm. · 1 autom.'; document.querySelector('#flowSaveStatus').textContent='Zapisano'; - document.querySelector('#flowInterpretation').textContent='Jeśli okno w salonie jest otwarte przez 30 s, wyłącz termostat Salon.'; - document.querySelector('#flowRuntimeSummary').textContent='Ostatnie wykonanie: 07:12 · wynik: prawda'; - document.querySelector('#flowEmptyHint').style.display='none'; - document.querySelector('#flowNodes').innerHTML=`
Wspólne wejście
Okno w salonie
Terazotwarte
Utrzymuje się przez…
30 sekund
Termostat
Salon · wyłącz
`; - document.querySelector('#flowInspector').classList.add('has-selection'); - document.querySelector('#flowInspector').innerHTML='
Ustawienia bloku

Wspólne wejście

Czujnik używany tylko we Flow. Nie jest zapisywany do metryk.

'; - } -}); diff --git a/mock/styles.css b/mock/styles.css deleted file mode 100644 index bd921f2..0000000 --- a/mock/styles.css +++ /dev/null @@ -1,6746 +0,0 @@ -:root { - color-scheme: dark; - --bg: #151515; - --canvas: #181818; - --surface: #1c1c1c; - --surface-2: #222222; - --surface-3: #282828; - --surface-muted: #202020; - --input-bg: #181818; - --nav-bg: #171717; - --dialog-bg: #1c1c1c; - --line: #2c2c2c; - --border: #2c2c2c; - --line-strong: #3a3a3a; - --text: #ededed; - --text-soft: #d4d4d4; - --muted: #a1a1aa; - --muted-2: #737373; - --muted-strong: #b5b5bd; - --accent: #3ecf8e; - --accent-strong: #46d99a; - --accent-soft: rgba(62, 207, 142, .10); - --accent-border: rgba(62, 207, 142, .36); - --accent-text: #06281a; - --danger: #f87171; - --danger-soft: rgba(248, 113, 113, .10); - --warning: #fbbf24; - --warning-soft: rgba(251, 191, 36, .10); - --info: #60a5fa; - --blue: #3b82f6; - --teal: #2dd4bf; - --purple: #a78bfa; - --orange: #fb923c; - --backdrop: rgba(0, 0, 0, .68); - --grid: rgba(255, 255, 255, .07); - --scroll-thumb: #444444; - --scroll-thumb-hover: #575757; - --radius: 16px; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -:root[data-theme="light"] { - color-scheme: light; - --bg: #f8faf9; - --canvas: #f8faf9; - --surface: #ffffff; - --surface-2: #f5f6f6; - --surface-3: #eef0ef; - --surface-muted: #f6f7f7; - --input-bg: #ffffff; - --nav-bg: #ffffff; - --dialog-bg: #ffffff; - --line: #e5e7e6; - --border: #e5e7e6; - --line-strong: #d4d7d5; - --text: #1c1c1c; - --text-soft: #3f3f46; - --muted: #71717a; - --muted-2: #a1a1aa; - --muted-strong: #52525b; - --accent: #24b47e; - --accent-strong: #1ea672; - --accent-soft: rgba(36, 180, 126, .09); - --accent-border: rgba(36, 180, 126, .32); - --accent-text: #ffffff; - --danger: #dc5f5f; - --danger-soft: rgba(220, 95, 95, .08); - --warning: #b7791f; - --warning-soft: rgba(183, 121, 31, .08); - --info: #2563eb; - --blue: #2563eb; - --teal: #0f9f8f; - --purple: #7c3aed; - --orange: #c56b21; - --backdrop: rgba(23, 23, 23, .32); - --grid: rgba(24, 24, 27, .08); - --scroll-thumb: #c8ccca; - --scroll-thumb-hover: #aeb3b0; -} - -* { - box-sizing: border-box; -} - -html, -body { - background: var(--bg); -} - -body { - margin: 0; - min-height: 100vh; - color: var(--text); -} - -button, -input, -select { - font: inherit; -} - -button { - cursor: pointer; -} - -button:disabled { - opacity: .5; - cursor: wait; -} - -.topbar { - position: sticky; - top: 0; - z-index: 20; - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - align-items: center; - gap: 1rem; - min-height: 72px; - padding: 12px max(18px, env(safe-area-inset-left)); - border-bottom: 1px solid var(--line); - background: color-mix(in srgb, var(--nav-bg) 94%, transparent); - backdrop-filter: blur(14px); -} - -.brand { - display: flex; - align-items: center; - justify-self: start; - min-width: 0; -} - -.brand strong, -.brand small { - display: block; -} - -.brand strong { - font-size: 15px; - letter-spacing: -.01em; - white-space: nowrap; -} - -.brand small { - margin-top: 2px; - color: var(--muted); - font-size: 11px; -} - -.brand.large { - margin-bottom: 1rem; -} - -.brand.large strong { - font-size: 21px; -} - -.top-actions { - display: flex; - align-items: center; - justify-content: flex-end; - justify-self: end; - gap: 7px; -} - -.toolbar-runtime-status { - justify-self: center; - padding: 7px 10px; - border: 1px solid var(--line); - border-radius: 999px; - color: var(--text-soft); - background: var(--surface-muted); - font-size: 11px; - font-weight: 750; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -button { - border: 0; - border-radius: 13px; - padding: 11px 15px; - color: var(--text); - background: var(--surface-2); - transition: transform .15s ease, background .15s ease, opacity .15s ease; -} - -button:active { - transform: scale(.97); -} - -.primary { - background: var(--accent); - color: var(--accent-text); - font-weight: 800; -} - -.primary:hover { - background: var(--accent-strong); -} - -.secondary { - border: 1px solid var(--line); - background: var(--surface-muted); - font-weight: 650; -} - -.secondary:hover { - background: var(--surface-2); -} - -.danger { - color: var(--danger); -} - -.compact { - padding: 10px 14px; -} - -.icon-button { - width: 40px; - height: 40px; - padding: 0; - font-size: 22px; - background: transparent; -} - -main { - width: min(1180px, 100%); - margin: 0 auto; - padding: 24px 18px 108px; -} - -.view { - display: none; - animation: reveal .2s ease; -} - -.view.active { - display: block; -} - -@keyframes reveal { - from { - opacity: 0; - transform: translateY(5px); - } -} - -h1, -h2, -h3, -p { - margin-top: 0; -} - -h1 { - margin-bottom: 8px; - font-size: clamp(28px, 6vw, 44px); - letter-spacing: -.045em; -} - -h2 { - margin-bottom: 0; - font-size: clamp(23px, 4vw, 31px); - letter-spacing: -.035em; -} - -h3 { - margin-bottom: 10px; -} - -.eyebrow { - color: var(--accent); - font-size: 11px; - font-weight: 850; - letter-spacing: .14em; - text-transform: uppercase; -} - -.lead { - max-width: 690px; - color: var(--muted); - line-height: 1.55; -} - -.hero { - position: relative; - overflow: hidden; - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 18px; - min-height: 210px; - padding: clamp(24px, 6vw, 44px); - border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); - border-radius: 30px; - background: var(--surface); -} - -.hero::after { - content: ""; - position: absolute; - right: -80px; - bottom: -120px; - width: 220px; - height: 220px; - border: 34px solid color-mix(in srgb, var(--accent) 7%, transparent); - border-radius: 50%; -} - -.hero h1 { - margin: 5px 0 8px; -} - -.hero p { - margin: 0; - color: var(--muted); -} - -.hero-temp { - z-index: 1; - white-space: nowrap; - font-size: clamp(48px, 12vw, 88px); - font-weight: 250; - letter-spacing: -.075em; -} - -.hero-temp small { - margin-left: 4px; - color: var(--muted); - font-size: .28em; - font-weight: 600; - letter-spacing: 0; -} - -.metrics { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 12px; - margin: 14px 0 34px; -} - -.metric { - padding: 18px; - border: 1px solid var(--line); - border-radius: 18px; - background: var(--surface); -} - -.metric span { - display: block; - margin-bottom: 8px; - color: var(--muted); - font-size: 12px; -} - -.metric strong { - font-size: 24px; - letter-spacing: -.03em; -} - -.section-heading { - display: flex; - align-items: end; - justify-content: space-between; - gap: 16px; - margin: 8px 0 18px; -} - -.section-heading-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; -} - -.device-grid, -.list-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); - gap: 14px; -} - -.device-card, -.panel, -.list-card { - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--surface); -} - -.device-card { - overflow: hidden; - padding: 20px; -} - -.device-card.off { - opacity: .86; -} - -.device-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.device-title { - min-width: 0; -} - -.device-title h3 { - overflow: hidden; - margin: 0 0 4px; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 19px; -} - -.device-title p { - overflow: hidden; - margin: 0; - color: var(--muted); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.status { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--muted); -} - -.status::before { - content: ""; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--danger); -} - -.status.online::before { - background: var(--accent); -} - -.power-button { - display: grid; - place-items: center; - flex: 0 0 auto; - width: 46px; - height: 46px; - padding: 0; - border-radius: 50%; - color: var(--muted); - font-size: 21px; -} - -.power-button.on { - background: var(--accent); - color: var(--accent-text); -} - -.temperature-control { - display: flex; - align-items: center; - justify-content: center; - gap: 22px; - padding: 24px 0 17px; -} - -.temperature-control button { - width: 42px; - height: 42px; - padding: 0; - border: 1px solid var(--line); - border-radius: 50%; - background: transparent; - font-size: 22px; -} - -.target-temp { - min-width: 106px; - text-align: center; - font-size: 48px; - font-weight: 300; - letter-spacing: -.06em; -} - -.target-temp small { - margin-left: 3px; - color: var(--muted); - font-size: 15px; - font-weight: 650; -} - -.editable-target[data-editable="true"], -.editable-target:not([data-editable]) { - cursor: text; - border-radius: 10px; - transition: background .15s ease, box-shadow .15s ease; -} - -.editable-target[data-editable="true"]:hover, -.editable-target[data-editable="true"]:focus-visible, -.editable-target:not([data-editable]):hover, -.editable-target:not([data-editable]):focus-visible { - background: var(--surface-2); - box-shadow: 0 0 0 1px var(--line); - outline: none; -} - -.editable-target.editing { - min-width: 92px; -} - -.inline-temperature-input { - width: 92px; - max-width: 100%; - padding: 3px 6px; - border: 1px solid var(--line); - border-radius: 9px; - background: var(--surface); - color: var(--text); - font: inherit; - font-size: .72em; - font-weight: 500; - text-align: center; - letter-spacing: 0; - outline: none; -} - -.inline-temperature-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); -} - -.hysteresis-config { - display: grid; - gap: 10px; -} - -.hysteresis-config .check { - margin-top: 0; -} - -.current-line { - display: flex; - justify-content: center; - gap: 6px; - margin: -10px 0 18px; - color: var(--muted); - font-size: 12px; -} - -.current-line strong { - color: var(--text); -} - -.mode-row, -.fan-row { - display: flex; - gap: 7px; - overflow-x: auto; - padding: 2px 0 10px; - scrollbar-width: none; -} - -.mode-row::-webkit-scrollbar, -.fan-row::-webkit-scrollbar { - display: none; -} - -.mode-row button, -.fan-row button { - flex: 1 0 auto; - min-width: 58px; - padding: 9px 10px; - color: var(--muted); - background: var(--surface-muted); - font-size: 12px; -} - -.mode-row button.active, -.fan-row button.active, -.house-mode-row button.active { - color: var(--accent-text); - background: var(--accent); - font-weight: 800; -} - -.device-toggles { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 7px; - margin-top: 4px; -} - -.device-toggles button { - padding: 9px 5px; - color: var(--muted); - font-size: 11px; -} - -.device-toggles button.active { - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--surface)); -} - -.card-footer { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - margin-top: 14px; - padding-top: 12px; - border-top: 1px solid var(--line); -} - -.card-footer small { - overflow: hidden; - color: var(--muted); - text-overflow: ellipsis; - white-space: nowrap; -} - -.card-menu { - display: flex; - gap: 5px; -} - -.card-menu button { - padding: 7px 9px; - background: transparent; - color: var(--muted); - font-size: 12px; -} - -.list-card { - padding: 19px; -} - -.list-card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.list-card h3 { - margin: 0 0 5px; - font-size: 18px; -} - -.list-card p { - margin: 0; - color: var(--muted); - font-size: 13px; - line-height: 1.45; -} - -.badge { - display: inline-flex; - align-items: center; - padding: 5px 9px; - border-radius: 99px; - color: var(--muted); - background: var(--surface-muted); - font-size: 11px; -} - -.badge.active { - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--surface)); -} - -.badge.manual-override { - color: var(--warning); - background: var(--warning-soft); -} - -.list-card.manual-takeover { - border-color: var(--warning); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--warning) 24%, transparent); -} - -.manual-override-panel { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 8px; - padding: 11px 12px; - border: 1px solid color-mix(in srgb, var(--warning) 45%, var(--line)); - border-radius: 12px; - background: var(--warning-soft); -} - -.manual-override-panel>div { - flex: 1 1 220px; - min-width: 0; -} - -.manual-override-panel strong { - display: block; - margin-bottom: 4px; - color: var(--warning); - font-size: 13px; -} - -.manual-override-panel p { - margin: 0; - color: var(--text); - font-size: 12px; - line-height: 1.45; -} - -.manual-override-panel button { - flex: 0 0 auto; - min-height: 34px; - padding: 7px 11px; -} - -.zone-enable-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 30px; - padding: 5px 9px; - border: 1px solid var(--line); - border-radius: 99px; - color: var(--muted); - background: var(--surface-muted); - font-size: 11px; -} - -.zone-enable-toggle.active { - border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--surface)); -} - -.zone-enable-toggle span { - font-size: 12px; - font-weight: 900; -} - -.zone-thermostat.zone-disabled .thermostat-main, -.zone-thermostat.zone-disabled .preset-row, -.zone-thermostat.zone-disabled .zone-mode-row, -.zone-thermostat.zone-disabled .zone-state-line { - opacity: .5; -} - -.card-stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 7px; - margin-top: 16px; -} - -.card-stat { - padding: 10px; - border-radius: 12px; - background: var(--surface-muted); - text-align: center; -} - -.card-stat small, -.card-stat strong { - display: block; -} - -.card-stat small { - margin-bottom: 5px; - color: var(--muted); - font-size: 10px; -} - -.card-stat strong { - font-size: 15px; -} - -.empty { - grid-column: 1/-1; - padding: 38px 24px; - border: 1px dashed var(--line); - border-radius: var(--radius); - color: var(--muted); - text-align: center; -} - -.empty strong { - display: block; - margin-bottom: 7px; - color: var(--text); -} - -.panel { - padding: 20px; -} - -.chart-panel { - overflow: hidden; -} - -.chart-toolbar { - display: flex; - flex-wrap: wrap; - gap: 9px; - margin-bottom: 18px; -} - -.chart-toolbar select { - flex: 1; - min-width: 140px; -} - -.chart-wrap { - position: relative; - overflow-x: auto; - min-height: 310px; -} - -#historyChart { - width: 100%; - min-width: 680px; - height: 340px; -} - -.legend { - display: flex; - gap: 18px; - margin-top: 10px; - color: var(--muted); - font-size: 12px; -} - -.legend>span { - display: flex; - align-items: center; - gap: 7px; -} - -.dialog-form hr { - width: 100%; - border: 0; - border-top: 1px solid var(--line); -} - -label { - display: grid; - gap: 7px; - color: var(--muted); - font-size: 12px; - font-weight: 650; -} - -input, -select { - width: 100%; - min-height: 45px; - border: 1px solid var(--line); - border-radius: 12px; - outline: 0; - padding: 10px 12px; - color: var(--text); - background: var(--input-bg); -} - -input:focus, -select:focus { - border-color: var(--accent); - outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); - outline-offset: 1px; -} - -.check { - display: flex; - grid-auto-flow: column; - justify-content: start; - align-items: center; - gap: 9px; - min-height: 44px; -} - -.check input { - width: 19px; - min-height: 19px; - accent-color: var(--accent); -} - -.form-actions { - display: flex; - justify-content: flex-end; - gap: 9px; - margin-top: 6px; -} - -.influx-fields { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 14px; - padding: 14px; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface-muted); -} - -.influx-fields[hidden] { - display: none; -} - -.influx-fields label:last-child:nth-child(odd) { - grid-column: 1/-1; -} - -.backup-actions { - align-items: center; -} - -.system-panel { - display: grid; - gap: 16px; - margin-top: 14px; - padding: 18px; -} - -.system-panel-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.system-panel-head h3 { - margin: 4px 0 5px; - font-size: 20px; -} - -.system-panel-head p { - max-width: 700px; - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.5; -} - -.system-health { - display: inline-flex; - align-items: center; - min-height: 30px; - padding: 6px 10px; - border: 1px solid var(--line); - border-radius: 999px; - color: var(--muted); - background: var(--surface-muted); - font-size: 10px; - font-weight: 800; - white-space: nowrap; -} - -.system-health.ok { - border-color: var(--accent-border); - color: var(--accent); - background: var(--accent-soft); -} - -.system-health.warn { - border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); - color: var(--warning); - background: var(--warning-soft); -} - -.system-status-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); - gap: 9px; -} - -.system-status-item { - min-width: 0; - padding: 12px; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface-muted); -} - -.system-status-item small, -.system-status-item strong { - display: block; -} - -.system-status-item small { - margin-bottom: 5px; - color: var(--muted); - font-size: 10px; -} - -.system-status-item strong { - overflow: hidden; - color: var(--text); - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.system-status-item.ok { - border-color: color-mix(in srgb, var(--accent) 25%, var(--line)); -} - -.system-status-item.warn { - border-color: color-mix(in srgb, var(--warning) 28%, var(--line)); - background: color-mix(in srgb, var(--warning-soft) 52%, var(--surface-muted)); -} - -.system-status-item.mono strong { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - font-size: 11px; -} - -.gree-frame-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 10px; -} - -.gree-frame-stat { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - min-width: 0; - padding: 12px 14px; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface-2); -} - -.gree-frame-stat small { - overflow: hidden; - color: var(--muted); - text-overflow: ellipsis; - white-space: nowrap; -} - -.gree-frame-stat strong { - color: var(--text); - font-variant-numeric: tabular-nums; -} - -.gree-frame-stat.total { - border-color: var(--accent-border); - background: var(--accent-soft); -} - -.quick-thermostat-control .zone-mode-row button:disabled { - opacity: .38; - cursor: not-allowed; - box-shadow: none; -} - -.log-list { - display: grid; - gap: 2px; - padding: 8px; -} - -.log-row { - display: grid; - grid-template-columns: 78px 150px 1fr; - gap: 12px; - padding: 11px 12px; - border-bottom: 1px solid var(--line); - font-size: 12px; -} - -.log-row:last-child { - border-bottom: 0; -} - -.log-row time, -.log-row .kind { - color: var(--muted); -} - -.log-row.error .kind { - color: var(--danger); -} - -.log-row.warn .kind { - color: var(--warning); -} - -.bottom-nav { - position: fixed; - z-index: 30; - right: 0; - bottom: 0; - left: 0; - display: grid; - grid-template-columns: repeat(5, 1fr); - padding: 8px max(8px, env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); - border-top: 1px solid var(--line); - background: color-mix(in srgb, var(--nav-bg) 96%, transparent); - backdrop-filter: blur(14px); -} - -.bottom-nav button { - display: grid; - place-items: center; - gap: 3px; - padding: 7px 2px; - border-radius: 12px; - color: var(--muted); - background: transparent; - font-size: 10px; -} - -.bottom-nav button span { - font-size: 20px; - line-height: 1; -} - -.bottom-nav button b { - font-weight: 650; -} - -.bottom-nav button.active { - color: var(--accent); -} - -dialog { - width: min(540px, calc(100% - 24px)); - max-height: min(88vh, 760px); - overflow: auto; - border: 1px solid var(--line); - border-radius: 24px; - padding: 0; - color: var(--text); - background: var(--dialog-bg); -} - -dialog::backdrop { - background: var(--backdrop); - backdrop-filter: blur(4px); -} - -.dialog-wide { - width: min(860px, calc(100% - 24px)); -} - -.dialog-form { - display: grid; - gap: 14px; - padding: 20px; -} - -.dialog-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.dialog-head h2 { - font-size: 23px; -} - -.dialog-head button { - width: 38px; - height: 38px; - padding: 0; - background: transparent; - color: var(--muted); - font-size: 25px; -} - -.two { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -#externalSensorFields { - display: grid; - gap: 12px; -} - -.field-note { - margin: -4px 0 0; - color: var(--muted); - font-size: .8rem; - line-height: 1.45; -} - -.sensor-detail { - margin-top: 10px; - color: var(--muted); - font-size: .8rem; - line-height: 1.45; -} - -fieldset { - border: 1px solid var(--line); - border-radius: 14px; - padding: 10px; -} - -legend { - padding: 0 5px; - color: var(--muted); - font-size: 11px; -} - -.days { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 4px; -} - -.days label { - display: grid; - place-items: center; - gap: 5px; - padding: 6px 2px; - border-radius: 8px; - background: var(--surface-muted); - font-size: 10px; -} - -.days input { - width: 16px; - min-height: 16px; - accent-color: var(--accent); -} - -.sheet { - width: min(500px, calc(100% - 18px)); - margin: auto auto 10px; -} - -.sheet .dialog-head { - padding: 18px 18px 5px; -} - -.menu-list { - display: grid; - padding: 8px 10px 12px; -} - -.menu-list button { - display: flex; - justify-content: space-between; - border-bottom: 1px solid var(--line); - border-radius: 0; - padding: 16px 9px; - background: transparent; - text-align: left; -} - -.menu-list button:last-child { - border-bottom: 0; -} - -.auth-dialog { - width: min(420px, calc(100% - 30px)); -} - -@media (min-width: 900px) { - .bottom-nav { - top: calc(var(--topbar-height, 72px) + 20px); - right: auto; - bottom: auto; - left: max(16px, calc((100vw - 1380px)/2)); - grid-template-columns: 1fr; - width: 90px; - border: 1px solid var(--line); - border-radius: 22px; - padding: 9px; - background: var(--surface); - } - - .bottom-nav button { - padding: 10px 4px; - } - - main { - padding-left: 122px; - padding-bottom: 50px; - } -} - -@media (max-width: 720px) { - .topbar { - flex-wrap: wrap; - } - - .top-actions { - width: 100%; - } - - .top-actions .primary { - margin-left: auto; - } -} - -@media (max-width: 620px) { - main { - padding: 18px 13px 105px; - } - - .hero { - min-height: 190px; - padding: 23px; - } - - .hero-temp { - font-size: 52px; - } - - .metrics { - gap: 7px; - } - - .metric { - padding: 14px 10px; - } - - .metric strong { - font-size: 20px; - } - - .section-heading { - align-items: center; - } - - .section-heading button { - padding: 9px 11px; - font-size: 12px; - } - - .log-row { - grid-template-columns: 62px 1fr; - } - - .log-row .message { - grid-column: 1/-1; - } - -} - -@media (max-width: 410px) { - .topbar { - padding-inline: 12px; - } - - .brand strong { - font-size: 13px; - } - - .hero { - align-items: start; - flex-direction: column; - } - - .hero-temp { - align-self: flex-end; - margin-top: -15px; - } - - .two { - grid-template-columns: 1fr; - } - -} - -.token-manager { - display: grid; - gap: 12px; -} - -.token-list { - display: grid; - gap: 8px; -} - -.token-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--surface-muted); -} - -.token-row>div { - min-width: 0; - display: grid; - gap: 3px; -} - -.token-row strong { - color: var(--text); - font-size: 13px; -} - -.token-row small { - overflow: hidden; - color: var(--muted); - text-overflow: ellipsis; - white-space: nowrap; -} - -.mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; -} - -.empty.compact { - padding: 18px; -} - -@media (max-width: 620px) { - .token-row { - align-items: stretch; - flex-direction: column; - } - - .token-row button { - width: 100%; - } -} - -.temperature-control.compact { - padding: 4px 0 7px; - gap: 16px; -} - -.temperature-control.compact button { - width: 38px; - height: 38px; -} - -.target-temp.compact { - min-width: 92px; - font-size: 32px; -} - -.discovery-name-list { - display: grid; - gap: 12px; -} - -.discovery-name-row { - display: grid; - gap: 8px; - padding: 12px 0; - border-bottom: 1px solid var(--border); -} - -.discovery-name-row:last-child { - border-bottom: 0; -} - -.discovery-name-row span { - display: grid; - gap: 2px; -} - -.discovery-name-row small { - color: var(--muted); - overflow-wrap: anywhere; -} - -/* Themed scrollbars: visible where scrolling is useful, but visually quiet. */ -* { - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--accent) 42%, var(--surface-2)) var(--surface-muted); -} - -*::-webkit-scrollbar { - width: 9px; - height: 9px; -} - -*::-webkit-scrollbar-track { - background: var(--surface-muted); - border-radius: 99px; -} - -*::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--accent) 38%, var(--surface-2)); - border: 2px solid var(--surface-muted); - border-radius: 99px; -} - -*::-webkit-scrollbar-thumb:hover { - background: color-mix(in srgb, var(--accent) 58%, var(--surface-2)); -} - -.mode-row, -.fan-row, -.preset-row { - scrollbar-width: none; -} - -.mode-row::-webkit-scrollbar, -.fan-row::-webkit-scrollbar, -.preset-row::-webkit-scrollbar { - display: none; -} - -.house-climate { - display: grid; - gap: 16px; - margin: 0 0 12px; -} - -.house-climate-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.house-climate-head h3 { - margin: 2px 0 4px; - font-size: 20px; -} - -.house-climate-head p { - margin: 0; - color: var(--muted); - font-size: 13px; - line-height: 1.45; -} - -.outside-pill { - flex: 0 0 auto; - min-width: 94px; - padding: 10px 13px; - border-radius: 14px; - background: var(--surface-muted); - text-align: right; -} - -.outside-pill small, -.outside-pill strong { - display: block; -} - -.outside-pill small { - color: var(--muted); - font-size: 10px; -} - -.outside-pill strong { - margin-top: 3px; - font-size: 18px; -} - -.automation-plan-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); - gap: 12px; - margin-bottom: 34px; -} - -.plan-card { - display: grid; - align-content: start; - gap: 11px; - min-height: 190px; - padding: 17px; -} - -.plan-card.disabled { - opacity: .65; -} - -.plan-card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; -} - -.plan-card-head h3 { - margin: 2px 0 0; - font-size: 19px; -} - -.plan-card>p { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} - -.plan-temp { - display: flex; - align-items: baseline; - gap: 9px; -} - -.plan-temp>span { - color: var(--muted); - font-size: 20px; -} - -.plan-temp>b { - color: var(--muted); - font-weight: 500; -} - -.plan-temp>strong { - font-size: 28px; - letter-spacing: -.04em; -} - -.plan-temp small { - margin-left: 2px; - color: var(--muted); - font-size: 11px; -} - -.plan-events { - display: grid; - gap: 6px; - margin: 0; - padding: 10px 0 0; - border-top: 1px solid var(--line); - list-style: none; -} - -.plan-events li { - display: grid; - grid-template-columns: 82px 1fr; - gap: 8px; - color: var(--text-soft); - font-size: 11px; - line-height: 1.35; -} - -.plan-events time { - color: var(--muted); - font-variant-numeric: tabular-nums; -} - -.plan-events .muted { - display: block; - color: var(--muted); -} - -.plan-house { - border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); -} - -.plan-loading { - grid-column: 1/-1; - padding: 18px; - color: var(--muted); -} - -.house-mode-row { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; -} - -.house-mode-row button { - min-height: 44px; - color: var(--muted); - background: var(--surface-muted); -} - -.house-power-actions { - display: flex; - flex: 0 0 auto; - gap: 8px; -} - -.house-power-actions button { - min-height: 40px; -} - -.house-power-actions button.active { - color: var(--accent-text); - background: var(--accent); - font-weight: 800; -} - -.zone-thermostat { - overflow: hidden; -} - -.zone-thermostat.demanding { - border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); -} - -.thermostat-main { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 8px; - margin: 15px 0 9px; -} - -.thermostat-main>div:first-child { - text-align: left; -} - -.thermostat-main>div:last-child { - text-align: right; -} - -.thermostat-main small, -.thermostat-main strong { - display: block; -} - -.thermostat-main small { - margin-bottom: 3px; - color: var(--muted); - font-size: 10px; -} - -.thermostat-main strong { - font-size: 16px; -} - -.preset-row { - display: flex; - gap: 6px; - overflow-x: auto; - padding: 3px 0 8px; -} - -.preset-row button { - flex: 1 0 auto; - min-height: 38px; - padding: 8px 11px; - color: var(--muted); - background: var(--surface-muted); - font-size: 12px; -} - -.preset-row button.active { - color: var(--accent-text); - background: var(--accent); - font-weight: 800; -} - -.zone-mode-row { - padding-bottom: 5px; -} - -.zone-state-line { - display: flex; - justify-content: space-between; - gap: 10px; - padding: 8px 1px 1px; - color: var(--muted); - font-size: 11px; -} - -.zone-state-line span:last-child { - text-align: right; -} - -.dashboard-zones { - margin-bottom: 34px; -} - -.schedule-template-panel { - display: grid; - gap: 13px; - margin-bottom: 18px; -} - -.schedule-template-panel h3 { - margin-bottom: 4px; -} - -.template-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); - gap: 8px; -} - -.profile-editor { - border: 1px solid var(--line); - border-radius: 14px; - padding: 11px 12px; - background: var(--surface-muted); -} - -.profile-editor summary { - cursor: pointer; - color: var(--text); - font-weight: 700; - font-size: 13px; -} - -.profile-editor[open] summary { - margin-bottom: 12px; -} - -.profile-grid { - display: grid; - grid-template-columns: 90px repeat(3, 1fr); - align-items: end; - gap: 8px; - margin-top: 8px; -} - -.profile-grid>strong { - align-self: center; - font-size: 12px; -} - -.profile-grid label span { - font-size: 10px; -} - -.warning-note { - color: var(--warning); -} - -@media (max-width: 620px) { - - .house-climate-head { - align-items: flex-start; - } - - .house-climate-head p { - max-width: 220px; - } - - .house-power-actions { - display: grid; - grid-template-columns: 1fr 1fr; - } - - .thermostat-main { - grid-template-columns: 1fr auto 1fr; - gap: 4px; - } - - .thermostat-main .temperature-control.compact { - gap: 9px; - } - - .thermostat-main .target-temp.compact { - min-width: 75px; - font-size: 28px; - } - - .profile-grid { - grid-template-columns: 1fr 1fr; - } - - .profile-grid>strong { - grid-column: 1/-1; - margin-top: 4px; - } - - .zone-state-line { - flex-direction: column; - gap: 2px; - } - - .zone-state-line span:last-child { - text-align: left; - } -} - -.automation-hint { - margin-bottom: 18px; -} - -.automation-hint strong { - display: block; - margin-bottom: 5px; -} - -.automation-hint p { - margin: 0; - color: var(--muted); - font-size: 13px; - line-height: 1.5; -} - -/* Rich climate history (v0.4.4) using the classic rounded UI. */ -.history-chart-card { - margin-top: 14px; -} - -.chart-title-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; -} - -.chart-title-row h3 { - margin: 0 0 4px; - font-size: 17px; -} - -.chart-title-row p { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} - -.history-summary { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 9px; -} - -.history-stat { - min-width: 0; - padding: 12px; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface-muted); -} - -.history-stat small, -.history-stat strong, -.history-stat span { - display: block; -} - -.history-stat small { - overflow: hidden; - color: var(--muted); - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.history-stat strong { - margin-top: 4px; - color: var(--text); - font-size: 18px; - font-weight: 700; -} - -.history-stat span { - overflow: hidden; - margin-top: 2px; - color: var(--muted-2); - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chart-wrap { - scrollbar-width: none; - -ms-overflow-style: none; -} - -.chart-wrap::-webkit-scrollbar { - display: none; -} - -.chart-wrap.compact-chart { - min-height: 230px; -} - -#historyTemperatureChart, -#historyOperationChart { - width: 100%; - min-width: 720px; -} - -#historyTemperatureChart { - height: 360px; -} - -#historyOperationChart { - height: 260px; -} - -.legend { - flex-wrap: wrap; - gap: 7px 10px; -} - -.legend-item { - display: inline-flex; - align-items: center; - gap: 7px; - min-height: 30px; - padding: 5px 8px; - border: 1px solid transparent; - border-radius: 9px; - background: transparent; - color: var(--muted); - font: inherit; - cursor: pointer; -} - -.legend-item:hover { - border-color: var(--line); - background: var(--surface-muted); - color: var(--text-soft); -} - -.legend-item.is-hidden { - opacity: .42; - text-decoration: line-through; -} - -.legend-item:focus-visible { - outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); - outline-offset: 1px; -} - -.legend-line { - flex: 0 0 18px; - width: 18px; - height: 3px; - border-radius: 999px; - background: var(--legend-color, var(--accent)); -} - -@media (max-width: 620px) { - .history-summary { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .chart-toolbar { - display: grid; - grid-template-columns: 1fr 1fr; - } - - .chart-toolbar button { - grid-column: 1 / -1; - } -} - -/* History navigation, custom chart composer and linkable sub-pages. */ -.history-tabs { - display: flex; - gap: 7px; - overflow-x: auto; - margin: 0 0 14px; - padding: 3px; - scrollbar-width: none; -} - -.history-tabs::-webkit-scrollbar { - display: none; -} - -.history-tabs button { - flex: 0 0 auto; - padding: 9px 13px; - border: 1px solid var(--line); - border-radius: 999px; - color: var(--muted); - background: var(--surface); - font-size: 12px; - font-weight: 700; -} - -.history-tabs button.active { - color: var(--accent-text); - border-color: var(--accent); - background: var(--accent); -} - -.history-toolbar-panel { - margin-bottom: 14px; -} - -.history-context-controls { - display: flex; - flex: 1 1 260px; - min-width: min(100%, 220px); -} - -.history-context-controls label { - width: 100%; -} - -.history-context-hint { - align-self: center; - color: var(--muted); - font-size: 12px; - line-height: 1.4; -} - -.history-charts { - display: grid; - gap: 14px; -} - -.history-custom-builder { - display: none; - margin-bottom: 14px; -} - -.history-custom-builder.active { - display: block; -} - -.custom-chart-panel { - display: grid; - gap: 13px; -} - -.custom-chart-add, -.custom-chart-save { - display: flex; - gap: 8px; - align-items: center; -} - -.custom-chart-add select, -.custom-chart-save input { - flex: 1; -} - -.custom-series-list { - display: flex; - flex-wrap: wrap; - gap: 7px; - min-height: 34px; - align-items: center; -} - -.custom-series-chip { - display: inline-flex; - align-items: center; - gap: 7px; - max-width: 100%; - padding: 7px 8px 7px 10px; - border: 1px solid var(--line); - border-radius: 999px; - background: var(--surface-muted); - color: var(--text-soft); - font-size: 11px; -} - -.custom-series-chip i { - width: 8px; - height: 8px; - flex: 0 0 auto; - border-radius: 50%; - background: var(--chip-color, var(--accent)); -} - -.custom-series-chip button { - width: 23px; - height: 23px; - padding: 0; - border-radius: 50%; - color: var(--muted); - background: transparent; - font-size: 16px; - line-height: 1; -} - -.saved-chart-list { - display: grid; - gap: 7px; - padding-top: 3px; - border-top: 1px solid var(--line); -} - -.saved-chart-row { - display: grid; - grid-template-columns: 1fr auto; - gap: 7px; -} - -.saved-chart-row>button:first-child { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - padding: 10px 12px; - border: 1px solid var(--line); - background: var(--surface-muted); - text-align: left; -} - -.saved-chart-row strong, -.saved-chart-row small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.saved-chart-row small { - color: var(--muted); -} - -.saved-chart-row>.danger { - width: 42px; - padding: 0; - border: 1px solid var(--line); - background: var(--surface-muted); -} - -/* Toast stack: quiet success/error feedback that matches light and dark themes. */ -.toast-stack { - position: fixed; - z-index: 120; - right: 18px; - bottom: 92px; - display: grid; - gap: 9px; - width: min(390px, calc(100vw - 36px)); - pointer-events: none; -} - -.toast-item { - position: relative; - display: grid; - grid-template-columns: 34px 1fr 28px; - gap: 10px; - align-items: center; - overflow: hidden; - padding: 12px 11px; - border: 1px solid var(--line); - border-radius: 16px; - background: color-mix(in srgb, var(--surface) 96%, transparent); - backdrop-filter: blur(16px); - opacity: 0; - transform: translateY(10px) scale(.98); - transition: opacity .2s ease, transform .2s ease; - pointer-events: auto; -} - -.toast-item.show { - opacity: 1; - transform: translateY(0) scale(1); -} - -.toast-item.leaving { - opacity: 0; - transform: translateX(18px) scale(.98); -} - -.toast-item.success { - border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); -} - -.toast-item.error { - border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); -} - -.toast-icon { - display: grid; - place-items: center; - width: 32px; - height: 32px; - border-radius: 50%; - color: var(--accent); - background: var(--accent-soft); - font-size: 15px; - font-weight: 900; -} - -.toast-item.error .toast-icon { - color: var(--danger); - background: var(--danger-soft); -} - -.toast-copy { - min-width: 0; -} - -.toast-copy strong, -.toast-copy span { - display: block; -} - -.toast-copy strong { - margin-bottom: 2px; - font-size: 12px; -} - -.toast-copy span { - overflow-wrap: anywhere; - color: var(--muted); - font-size: 11px; - line-height: 1.35; -} - -.toast-close { - width: 28px; - height: 28px; - padding: 0; - border-radius: 50%; - color: var(--muted); - background: transparent; - font-size: 19px; -} - -.toast-progress { - position: absolute; - right: 11px; - bottom: 3px; - left: 11px; - height: 2px; - border-radius: 99px; - background: var(--accent); - transform-origin: left; - animation: toast-progress 3.6s linear forwards; -} - -.toast-item.error .toast-progress { - background: var(--danger); - animation-duration: 5.2s; -} - -@keyframes toast-progress { - from { - transform: scaleX(1); - } - - to { - transform: scaleX(0); - } -} - -.debug-overlay { - position: fixed; - z-index: 110; - right: 18px; - bottom: 92px; - width: min(680px, calc(100vw - 36px)); - max-height: min(46vh, 440px); - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); - border-radius: 18px; - background: color-mix(in srgb, var(--surface) 96%, transparent); - box-shadow: 0 18px 60px rgba(0, 0, 0, .22); - backdrop-filter: blur(18px); -} - -.debug-overlay[hidden] { - display: none; -} - -.debug-overlay-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - border-bottom: 1px solid var(--line); -} - -.debug-overlay-title { - display: grid; - flex: 0 1 auto; - gap: 2px; - min-width: 90px; -} - -.debug-overlay-head strong { - font-size: 12px; -} - -.debug-overlay-head small { - color: var(--muted); - font-size: 10px; -} - -.debug-overlay-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 7px; - min-width: 0; -} - -.debug-source-tabs { - display: flex; - align-items: center; - gap: 3px; - padding: 3px; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface-muted); -} - -.debug-source-tabs button, -.debug-overlay-head .debug-clear { - min-height: 28px; - padding: 5px 8px; - border-radius: 7px; - color: var(--muted); - background: transparent; - font-size: 9px; - font-weight: 750; -} - -.debug-source-tabs button.active { - color: var(--accent); - background: var(--accent-soft); -} - -.debug-overlay-head .debug-clear { - border: 1px solid var(--line); - background: var(--surface-muted); -} - -.debug-overlay-lines { - max-height: min(38vh, 360px); - overflow: auto; - padding: 7px; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; -} - -.debug-line { - display: grid; - grid-template-columns: 70px 48px 1fr; - gap: 8px; - margin: 3px 0; - padding: 7px 8px; - border: 1px solid var(--line); - border-radius: 9px; - background: color-mix(in srgb, var(--surface-muted) 76%, transparent); - font-size: 10px; - line-height: 1.35; -} - -.debug-line.source-gree { - border-color: color-mix(in srgb, var(--accent) 25%, var(--line)); -} - -.debug-line.source-request { - border-color: color-mix(in srgb, var(--info) 24%, var(--line)); -} - -.debug-line time, -.debug-line b { - color: var(--muted); - font-weight: 650; -} - -.debug-line.source-gree b { - color: var(--accent); -} - -.debug-line.source-request b { - color: var(--info); -} - -.debug-line span { - overflow-wrap: anywhere; - white-space: pre-wrap; -} - -.debug-empty { - padding: 18px; - color: var(--muted); - text-align: center; - font-size: 11px; -} - -@media (max-width: 620px) { - - .custom-chart-add, - .custom-chart-save { - align-items: stretch; - flex-direction: column; - } - - .toast-stack { - right: 12px; - bottom: 88px; - width: calc(100vw - 24px); - } - - .debug-overlay { - right: 12px; - bottom: 88px; - width: calc(100vw - 24px); - } - - .debug-overlay-head { - align-items: stretch; - flex-direction: column; - } - - .debug-overlay-actions { - justify-content: space-between; - } - - .debug-source-tabs { - flex: 1 1 auto; - } - - .debug-source-tabs button { - flex: 1 1 auto; - } - - .debug-line { - grid-template-columns: 58px 42px 1fr; - } - - .system-panel-head { - align-items: stretch; - flex-direction: column; - } - - .system-health { - align-self: flex-start; - } - - .simulation-mode-banner { - align-items: stretch; - flex-direction: column; - gap: 3px; - } - - .simulation-mode-banner strong { - white-space: normal; - } - - .influx-fields { - grid-template-columns: 1fr; - } - - .influx-fields label:last-child:nth-child(odd) { - grid-column: auto; - } -} - -.history-chart-card canvas { - width: 100%; - min-width: 720px; -} - -.simulation-summary-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); - gap: 12px; - margin-bottom: 18px; -} - -.simulation-summary-card { - display: grid; - gap: 6px; - padding: 16px 18px; -} - -.simulation-summary-card small { - color: var(--muted); - font-size: 11px; - font-weight: 700; -} - -.simulation-summary-card strong { - font-size: 28px; - letter-spacing: -.04em; -} - -.simulation-summary-card span { - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} - -.simulation-section-panel { - margin-bottom: 18px; - padding: 0; - overflow: hidden; -} - -.simulation-panel-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 15px 18px 14px; - border-bottom: 1px solid var(--line); - background: color-mix(in srgb, var(--surface-muted) 52%, var(--surface)); -} - -.simulation-panel-head > div:first-child { - min-width: 0; -} - -.simulation-panel-head .eyebrow { - font-size: 9px; -} - -.simulation-panel-head h2 { - margin: 3px 0 0; - font-size: 19px; - letter-spacing: -.025em; -} - -.simulation-panel-body { - padding: 16px 18px 18px; -} - -.simulation-timeline { - display: grid; -} - -.simulation-timeline-item { - display: grid; - grid-template-columns: 110px minmax(0, 1fr); - gap: 12px; - align-items: start; - padding: 12px 0; - border-bottom: 1px solid var(--line); -} - -.simulation-timeline-item:first-child { - padding-top: 0; -} - -.simulation-timeline-item:last-child { - padding-bottom: 0; - border-bottom: 0; -} - -.simulation-timeline-item time { - color: var(--muted); - font-size: 12px; - font-variant-numeric: tabular-nums; -} - -.simulation-timeline-item strong { - display: block; - margin-bottom: 4px; - font-size: 14px; -} - -.simulation-timeline-item p { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} - -.simulation-rule-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; -} - -.simulation-rule-head h3 { - margin: 3px 0 0; - font-size: 16px; -} - -.simulation-rule-meta small { - color: var(--muted); - font-size: 11px; -} - -.simulation-rules-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); - gap: 12px; -} - -.simulation-rule-card { - display: grid; - gap: 12px; - min-width: 0; - padding: 14px; - border: 1px solid var(--line); - border-radius: 16px; - background: var(--surface-muted); -} - -.simulation-rule-card > p { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} - -.simulation-rule-action { - padding: 11px 12px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--surface); - font-size: 13px; - line-height: 1.5; -} - -.simulation-rule-meta { - display: flex; - flex-wrap: wrap; - gap: 6px 12px; - color: var(--muted); -} - -@media (max-width: 640px) { - .simulation-timeline-item { - grid-template-columns: 1fr; - gap: 5px; - } -} - -.brand-line { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.brand-line strong { - display: block; -} - -.connection-dot { - display: inline-block !important; - width: 9px; - height: 9px; - flex: 0 0 9px; - margin: 0 !important; - border-radius: 50%; - background: var(--muted); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--muted) 15%, transparent); -} - -.connection-dot.connected { - background: #37c96b; - box-shadow: 0 0 0 3px rgba(55, 201, 107, .14), 0 0 10px rgba(55, 201, 107, .3); -} - -.connection-dot.disconnected, -.connection-dot.connectionError { - background: #ef5b5b; - box-shadow: 0 0 0 3px rgba(239, 91, 91, .14), 0 0 10px rgba(239, 91, 91, .24); -} - -.connection-dot.connecting { - background: #e2a93b; - box-shadow: 0 0 0 3px rgba(226, 169, 59, .14); - animation: connection-pulse 1.25s ease-in-out infinite; -} - -@keyframes connection-pulse { - 50% { - opacity: .45; - transform: scale(.86); - } -} - -/* v0.5.3 usability refinements */ -.history-toolbar-panel .chart-toolbar { - display: grid; - grid-template-columns: minmax(260px, 1fr) minmax(180px, .55fr) auto; - gap: 12px; - align-items: end; -} - -.history-toolbar-panel .chart-toolbar>* { - min-width: 0; -} - -.history-context-controls { - display: block; - width: 100%; - min-width: 0; -} - -.history-context-controls label, -.history-range-control { - width: 100%; -} - -.history-range-control { - display: grid; - gap: 7px; -} - -.history-refresh-button { - min-height: 45px; - align-self: end; -} - -.settings-form { - display: grid; - gap: 14px; -} - -.settings-mode-tabs { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 7px; - margin: 0 0 14px; - padding: 6px; - border: 1px solid var(--line); - border-radius: 17px; - background: var(--surface); -} - -.settings-mode-tabs button { - min-height: 42px; - border: 1px solid transparent; - border-radius: 12px; - color: var(--muted); - background: transparent; - font-size: 12px; - font-weight: 800; -} - -.settings-mode-tabs button.active { - border-color: var(--accent-border); - color: var(--accent); - background: var(--accent-soft); -} - -.settings-pane { - display: grid; - gap: 14px; -} - -.settings-pane[hidden] { - display: none; -} - -.settings-block { - display: grid; - gap: 18px; -} - -.settings-block-head { - display: flex; - align-items: flex-start; - gap: 13px; -} - -.settings-block-head h3 { - margin: 1px 0 4px; - font-size: 18px; -} - -.settings-block-head p { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.5; -} - -.settings-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} - -.settings-grid .wide { - grid-column: 1/-1; -} - -.settings-save-bar { - position: sticky; - bottom: 18px; - z-index: 9; - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - padding: 13px 15px; - border: 1px solid var(--line-strong); - border-radius: 16px; - background: color-mix(in srgb, var(--surface) 94%, transparent); - box-shadow: 0 8px 30px rgba(0, 0, 0, .18); - backdrop-filter: blur(16px); -} - -.settings-save-bar span { - color: var(--muted); - font-size: 12px; -} - -.sensor-alias-manager { - padding: 14px; - border: 1px solid var(--line); - border-radius: 16px; - background: var(--surface-muted); -} - -.sensor-alias-list { - display: grid; - gap: 8px; -} - -.sensor-alias-row { - display: grid; - grid-template-columns: minmax(180px, 1fr) minmax(150px, 1fr) 34px; - gap: 8px; - align-items: center; -} - -.sensor-alias-row>span { - overflow: hidden; - color: var(--muted); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.sensor-alias-row input { - min-height: 40px; -} - -.sensor-alias-clear { - width: 34px; - height: 34px; - padding: 0; - border-radius: 10px; - color: var(--muted); - background: transparent; -} - -.sensor-alias-add { - display: grid; - grid-template-columns: minmax(180px, 1fr) minmax(150px, 1fr) auto; - gap: 8px; - margin-top: 10px; -} - -.logs-toolbar { - display: grid; - grid-template-columns: 1fr minmax(160px, 220px) auto; - gap: 14px; - align-items: end; - margin-bottom: 14px; -} - -.logs-toolbar>div strong, -.logs-toolbar>div small { - display: block; -} - -.logs-toolbar>div small { - margin-top: 4px; - color: var(--muted); - font-size: 11px; - line-height: 1.4; -} - -.flow-legend { - display: flex; - flex-wrap: wrap; - gap: 12px; - color: var(--muted); - font-size: 11px; -} - -.flow-legend span { - display: flex; - align-items: center; - gap: 6px; -} - -.flow-legend b { - font-weight: 650; -} - -.flow-legend-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--muted); -} - -.flow-legend-dot.input { - background: var(--teal); -} - -.flow-legend-dot.logic { - background: var(--purple); -} - -.flow-legend-dot.action { - background: var(--accent); -} - -.simulation-flow-scroll { - overflow: auto; - max-width: 100%; - scrollbar-width: thin; -} - -.simulation-flow-board { - position: relative; - min-width: 1325px; - background-color: var(--surface-muted); - background-image: radial-gradient(circle, var(--grid) 1px, transparent 1.5px); - background-size: 20px 20px; -} - -.flow-links { - position: absolute; - inset: 0; - z-index: 1; - pointer-events: none; - overflow: visible; -} - -.flow-link { - fill: none; - stroke: color-mix(in srgb, var(--muted) 48%, transparent); - stroke-width: 2; - vector-effect: non-scaling-stroke; -} - -.flow-link.bus { - stroke-dasharray: 5 6; - opacity: .55; -} - -.flow-link.input-link { - stroke: color-mix(in srgb, var(--teal) 65%, var(--muted)); -} - -.flow-link.logic-link { - stroke: color-mix(in srgb, var(--purple) 58%, var(--muted)); -} - -.flow-link.action-link { - stroke: color-mix(in srgb, var(--accent) 60%, var(--muted)); -} - -.flow-link.active-link { - stroke: var(--accent); - stroke-width: 3; - filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent) 35%, transparent)); -} - -.flow-link.night-link { - stroke: var(--info); - stroke-dasharray: 6 5; - stroke-width: 2.5; -} - -.flow-lane-label { - position: absolute; - z-index: 2; - left: 45px; - width: 1230px; - display: flex; - align-items: center; - gap: 9px; - color: var(--muted); - font-size: 11px; -} - -.flow-lane-label::after { - content: ""; - height: 1px; - flex: 1; - background: var(--line); -} - -.flow-lane-label strong { - color: var(--text-soft); - font-size: 12px; -} - -.flow-node { - position: absolute; - z-index: 3; - display: grid; - align-content: start; - gap: 6px; - padding: 12px 13px; - border: 1px solid var(--line-strong); - border-radius: 12px; - background: color-mix(in srgb, var(--surface) 96%, transparent); - box-shadow: 0 8px 24px rgba(0, 0, 0, .15); -} - -.flow-node.input { - border-left: 3px solid var(--teal); -} - -.flow-node.logic { - border-left: 3px solid var(--purple); -} - -.flow-node.action { - border-left: 3px solid var(--accent); -} - -.flow-node.event { - border-left: 3px solid var(--info); -} - -.flow-node.decision.demand { - box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent), 0 8px 24px rgba(0, 0, 0, .15); -} - -.flow-node.global { - min-height: 106px; -} - -.flow-node.night-active { - border-color: color-mix(in srgb, var(--info) 45%, var(--line-strong)); -} - -.flow-node-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - color: var(--muted); - font-size: 9px; - font-weight: 800; - letter-spacing: .06em; - text-transform: uppercase; -} - -.flow-node h3 { - overflow: hidden; - margin: 0; - color: var(--text); - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.flow-node>strong { - color: var(--text); - font-size: 17px; - line-height: 1.25; -} - -.flow-node p { - display: -webkit-box; - overflow: hidden; - margin: 0; - color: var(--muted); - font-size: 10px; - line-height: 1.35; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -.flow-node-badge { - max-width: 86px; - overflow: hidden; - padding: 3px 6px; - border-radius: 999px; - color: var(--muted); - background: var(--surface-muted); - font-size: 8px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.flow-node-badge.active { - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--surface)); -} - -.flow-port { - position: absolute; - top: 50%; - width: 9px; - height: 9px; - margin-top: -5px; - border: 2px solid var(--surface); - border-radius: 50%; - background: var(--muted); -} - -.flow-port.in { - left: -6px; -} - -.flow-port.out { - right: -6px; -} - -.flow-node.input .flow-port { - background: var(--teal); -} - -.flow-node.logic .flow-port { - background: var(--purple); -} - -.flow-node.action .flow-port { - background: var(--accent); -} - -.flow-node.event .flow-port { - background: var(--info); -} - -@media (max-width: 760px) { - .history-toolbar-panel .chart-toolbar { - grid-template-columns: 1fr 1fr; - } - - .history-context-controls { - grid-column: 1/-1; - } - - .history-refresh-button { - grid-column: auto; - } - - .settings-grid { - grid-template-columns: 1fr; - } - - .settings-grid .wide { - grid-column: auto; - } - - .settings-save-bar { - bottom: 76px; - } - - .sensor-alias-row, - .sensor-alias-add { - grid-template-columns: 1fr; - } - - .sensor-alias-clear { - justify-self: end; - } - - .logs-toolbar { - grid-template-columns: 1fr; - align-items: stretch; - } - - .simulation-panel-head { - align-items: flex-start; - flex-direction: column; - } -} - -/* v0.5.4: settings headers no longer use decorative icons. */ -.settings-block-head { - gap: 0; -} - -.device-capability-panel { - display: grid; - gap: 8px; - padding: 12px 0 2px; -} - -.device-capability-panel>small { - color: var(--muted); - font-size: 11px; - text-transform: uppercase; - letter-spacing: .08em; -} - -.device-capability-buttons { - display: flex; - flex-wrap: wrap; - gap: 7px; -} - -.device-capability-buttons button { - min-height: 34px; - padding: 7px 11px; - border: 1px solid var(--line); - border-radius: 11px; - background: var(--surface-muted); - color: var(--text); -} - -.device-capability-buttons button.active { - border-color: var(--accent); - color: var(--accent); -} - -/* compact, centered unit-feature controls */ -.device-capability-panel { - justify-items: center; - text-align: center; - padding: 11px 0 3px; -} - -.device-capability-panel>small { - text-align: center; -} - -.device-capability-buttons { - justify-content: center; - align-items: center; - gap: 6px; - max-width: 100%; -} - -.device-capability-buttons button { - min-height: 28px; - padding: 5px 9px; - border-radius: 10px; - color: var(--muted); - font-size: 10px; - line-height: 1.1; - background: transparent; -} - -.device-capability-buttons button:hover { - color: var(--text); - background: var(--surface-muted); -} - -.device-capability-buttons button.active { - color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--surface)); - border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); -} - -.standalone-settings-form { - max-width: 1100px; -} - -.copy-settings-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: .6rem; - align-items: end -} - -.notification-provider { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: .75rem -} - -.log-row { - display: grid; - grid-template-columns: 5.5rem 7rem minmax(9rem, auto) 1fr; - gap: .75rem; - align-items: center; - border-left: 4px solid transparent -} - -.log-row.error { - border-left-color: var(--danger, #d9544d) -} - -.log-row.warn { - border-left-color: #c98a21 -} - -.log-row.info { - border-left-color: var(--accent, #4d9c7d) -} - -.log-category { - font-size: .72rem; - text-transform: uppercase; - letter-spacing: .08em; - opacity: .7 -} - -.category-device .log-category:before { - content: '▣ ' -} - -.category-zone .log-category:before { - content: '◎ ' -} - -.category-automation .log-category:before { - content: '⚙ ' -} - -.category-integration .log-category:before { - content: '↔ ' -} - -.category-settings .log-category:before { - content: '☷ ' -} - -.category-system .log-category:before { - content: '• ' -} - -@media(max-width:720px) { - - .copy-settings-row, - .notification-provider { - grid-template-columns: 1fr - } - - .log-row { - grid-template-columns: 4.5rem 1fr - } - - .log-row .kind, - .log-row .message { - grid-column: 2 - } - - .logs-toolbar { - align-items: stretch - } -} - -/* Group controls and standalone simulator */ -[hidden] { - display: none !important; -} - -.group-quick-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); - gap: 12px; -} - -.group-card { - display: grid; - gap: 13px; -} - -.group-card.group-off { - opacity: .78; -} - -.group-state-summary { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; -} - -.group-state-summary>div { - padding: 10px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--surface-muted); -} - -.group-state-summary small, -.group-control-block>small { - display: block; - margin-bottom: 5px; - color: var(--muted); - font-size: 10px; - text-transform: uppercase; - letter-spacing: .06em; -} - -.group-state-summary strong { - display: block; - overflow: hidden; - font-size: 13px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.group-control-block { - display: grid; - gap: 6px; -} - -.group-button-row { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 7px; -} - -.group-button-row button, -.group-mode-row button, -.group-preset-row button { - min-height: 36px; -} - -.group-button-row button.active { - color: var(--accent-text); - background: var(--accent); - font-weight: 800; -} - -.group-custom-temperature { - padding: 8px 9px; - border: 1px solid var(--line); - border-radius: 11px; - background: var(--surface-muted); -} - -.group-custom-temperature label>span { - display: block; - margin-bottom: 5px; - color: var(--muted); - font-size: 10px; -} - -.group-custom-temperature-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 6px; -} - -.group-custom-temperature-row input { - min-width: 0; - height: 36px; - text-align: center; - font-weight: 700; -} - -.group-custom-temperature-row>span { - color: var(--muted); - font-size: 12px; -} - -.group-custom-temperature-row button { - min-height: 36px; - white-space: nowrap; -} - -.group-zone-choices { - display: grid; - gap: 7px; - max-height: 300px; - overflow: auto; -} - -.group-zone-choice { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 10px; - padding: 10px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--surface-muted); -} - -.group-zone-choice input { - width: 18px; - min-height: 18px; - accent-color: var(--accent); -} - -.group-zone-choice strong, -.group-zone-choice small { - display: block; -} - -.group-zone-choice small { - margin-top: 3px; - color: var(--muted); - font-size: 10px; -} - -.simulation-page-head { - align-items: flex-start; -} - -.simulation-head-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 8px; -} - -.simulation-scope-toolbar { - display: flex; - align-items: end; - gap: 12px; - margin-bottom: 16px; - padding: 14px; -} - -.simulation-scope-toolbar label { - min-width: min(260px, 100%); -} - -body.simulation-standalone .topbar, -body.simulation-standalone .bottom-nav { - display: none !important; -} - -body.simulation-standalone main { - width: 100%; - max-width: none; - margin: 0; - padding: 18px; -} - -body.simulation-standalone [data-view="simulation"] { - min-height: calc(100vh - 36px); -} - -:fullscreen main { - width: 100%; - max-width: none; - padding: 18px; -} - -:fullscreen .topbar, -:fullscreen .bottom-nav { - display: none !important; -} - -@media (max-width:720px) { - - .simulation-page-head { - align-items: stretch; - flex-direction: column; - } - - .simulation-head-actions { - justify-content: stretch; - } - - .simulation-head-actions button { - flex: 1 1 180px; - } - - .simulation-scope-toolbar { - align-items: stretch; - flex-direction: column; - } - - .group-state-summary { - grid-template-columns: 1fr; - } -} - -/* Dashboard hierarchy: compact global controls -> wrapped groups -> on-demand details. */ -[data-view="dashboard"] .hero { - min-height: 164px; - padding: 24px 28px; - border-radius: 24px; -} - -[data-view="dashboard"] .hero h1 { - margin-bottom: 6px; -} - -[data-view="dashboard"] .hero-temp { - font-size: clamp(44px, 8vw, 68px); -} - -[data-view="dashboard"] .metrics { - gap: 10px; - margin: 10px 0 20px; -} - -[data-view="dashboard"] .metric { - padding: 14px 16px; - border-radius: 15px; -} - -[data-view="dashboard"] .metric span { - margin-bottom: 5px; -} - -[data-view="dashboard"] .metric strong { - font-size: 21px; -} - -.dashboard-global-panel { - display: grid; - gap: 14px; - padding: 16px 18px; - margin: 0 0 20px; -} - -.dashboard-global-power { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.dashboard-global-power h3 { - margin: 2px 0 3px; - font-size: 18px; -} - -.dashboard-global-power p { - margin: 0; - max-width: 680px; - color: var(--muted); - font-size: 11px; - line-height: 1.4; -} - -.dashboard-global-divider { - height: 1px; - background: var(--line); -} - -.dashboard-house-climate { - margin: 0; - gap: 12px; -} - -.dashboard-house-climate .house-climate-head h3 { - font-size: 18px; -} - -.dashboard-house-climate .house-climate-head p { - font-size: 11px; -} - -.dashboard-house-climate .outside-pill { - padding: 8px 11px; -} - -.dashboard-house-climate .house-mode-row button { - min-height: 38px; - padding: 8px 10px; -} - -.dashboard-house-climate .house-preset-row { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 6px; - overflow: visible; - padding: 0; -} - -.dashboard-house-climate .house-preset-row button { - min-width: 0; - min-height: 36px; - padding: 7px 8px; - font-size: 11px; -} - -.dashboard-primary-section { - margin-bottom: 18px; -} - -.dashboard-section-head { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 16px; - margin: 0 0 10px; -} - -.dashboard-section-head h2 { - margin: 3px 0 4px; - font-size: 21px; -} - -.dashboard-section-head p { - margin: 0; - max-width: 720px; - color: var(--muted); - font-size: 11px; - line-height: 1.4; -} - -.dashboard-section-actions { - display: flex; - align-items: center; - flex: 0 0 auto; - gap: 7px; -} - -.dashboard-section-actions .badge { - min-width: 28px; - justify-content: center; -} - -.dashboard-section-actions button { - min-height: 36px; - padding: 8px 11px; -} - -/* Groups wrap into rows. Horizontal scrolling is intentionally disabled on the dashboard. */ -.dashboard-group-strip { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); - gap: 10px; - overflow: visible; - padding: 0; -} - -.dashboard-group-strip .group-card { - min-width: 0; - padding: 13px; - gap: 8px; -} - -.dashboard-group-strip .list-card-head { - gap: 9px; -} - -.dashboard-group-strip .list-card-head h3 { - font-size: 16px; -} - -.dashboard-group-strip .list-card-head p { - display: -webkit-box; - overflow: hidden; - max-width: none; - font-size: 11px; - line-height: 1.35; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - white-space: normal; -} - -.dashboard-group-strip .badge { - padding: 4px 7px; - font-size: 10px; -} - -.dashboard-group-strip .group-control-block { - gap: 4px; -} - -.dashboard-group-strip .group-control-block>small { - margin-bottom: 1px; - font-size: 9px; -} - -.dashboard-group-strip .group-button-row { - gap: 5px; -} - -.dashboard-group-strip .group-mode-row, -.dashboard-group-strip .group-preset-row { - display: grid; - gap: 5px; - overflow: visible; - padding: 0; -} - -.dashboard-group-strip .group-mode-row { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.dashboard-group-strip .group-preset-row { - grid-template-columns: repeat(5, minmax(0, 1fr)); -} - -.dashboard-group-strip .group-button-row button, -.dashboard-group-strip .group-mode-row button, -.dashboard-group-strip .group-preset-row button { - min-width: 0; - min-height: 32px; - padding: 6px 5px; - font-size: 10px; - line-height: 1.15; -} - -/* Dashboard quick controls share one compact visual language. */ -.quick-control-card { - --quick-control-height: 28px; - --quick-control-gap: 4px; - padding: 14px; -} - -.quick-control-card .quick-control-row { - display: grid; - gap: var(--quick-control-gap); - overflow: visible; - padding: 2px 0 5px; -} - -.quick-control-card .quick-control-row-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.quick-control-card .quick-control-row-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.quick-control-card .quick-control-row-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); -} - -/* Polish mode labels are noticeably wider than Auto/Nawiew/Grzanie. - Give Cool/Dry more room instead of shrinking or clipping their text. */ -.quick-device-control .mode-row.quick-control-row-5 { - grid-template-columns: minmax(0, .8fr) minmax(0, 1.35fr) minmax(0, 1.2fr) minmax(0, .85fr) minmax(0, .8fr); -} - -.quick-device-control .mode-row.quick-control-row-5 button { - padding-inline: 3px; -} - -.quick-control-card .quick-control-row button { - min-width: 0; - min-height: var(--quick-control-height); - padding: 4px 5px; - font-size: 9px; - line-height: 1.05; - white-space: nowrap; -} - -.quick-control-card .power-button { - width: 38px; - height: 38px; - font-size: 18px; -} - -.quick-control-card .temperature-control button { - width: 34px; - height: 34px; - font-size: 18px; -} - -.quick-control-card .target-temp { - min-width: 80px; - font-size: 32px; -} - -.quick-control-card .device-title h3, -.quick-control-card .list-card-head h3 { - font-size: 16px; -} - -.quick-control-card .device-title p, -.quick-control-card .list-card-head p { - font-size: 11px; -} - -.quick-device-control .temperature-control { - gap: 12px; - padding: 12px 0 10px; -} - -.quick-device-control .current-line { - margin: -3px 0 10px; - font-size: 11px; -} - -.quick-device-control .device-toggles.quick-control-row { - margin-top: 1px; -} - -.quick-thermostat-control .zone-quick-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 5px; - min-width: 128px; -} - -.quick-thermostat-control .zone-quick-actions button { - display: flex; - align-items: center; - justify-content: center; - gap: 5px; - min-width: 0; - min-height: 30px; - padding: 5px 7px; - font-size: 10px; - white-space: nowrap; -} - -.quick-thermostat-control .zone-quick-actions button span { - font-size: 13px; - line-height: 1; -} - -.quick-thermostat-control .zone-quick-actions button b { - overflow: hidden; - font-size: 10px; - font-variant-numeric: tabular-nums; - text-overflow: ellipsis; -} - -.quick-thermostat-control .zone-unit-power-toggle.active { - color: var(--accent-text); - background: var(--accent); - font-weight: 800; -} - -.quick-thermostat-control .temporary-thermostat-toggle.active { - border-color: color-mix(in srgb, var(--warning) 55%, var(--line)); - color: var(--warning); - background: var(--warning-soft); -} - -.quick-thermostat-control .thermostat-main { - margin: 8px 0 7px; -} - -.zone-config-card .zone-enable-toggle { - min-height: 28px; - padding: 4px 8px; - font-size: 10px; -} - -.temporary-thermostat-form { - gap: 16px; -} - -.temporary-rule-fields { - display: grid; - gap: 12px; - padding: 13px; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface-muted); -} - -.temporary-rule-fields[hidden] { - display: none; -} - -.temporary-presets { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 5px; -} - -.temporary-presets button { - min-height: 32px; - padding: 6px; - font-size: 10px; -} - -.temporary-active-summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 13px; - border: 1px solid color-mix(in srgb, var(--warning) 45%, var(--line)); - border-radius: 14px; - background: var(--warning-soft); -} - -.temporary-active-summary[hidden] { - display: none; -} - -.temporary-active-summary>div { - display: grid; - gap: 3px; - min-width: 0; -} - -.temporary-active-summary small, -.temporary-active-summary span { - color: var(--muted); - font-size: 10px; - line-height: 1.35; -} - -.temporary-active-summary strong { - color: var(--warning); - font-size: 22px; - font-variant-numeric: tabular-nums; - letter-spacing: .02em; -} - -.temporary-active-summary .danger { - flex: 0 0 auto; -} - -@media (max-width: 700px) { - [data-view="dashboard"] .hero { - min-height: 145px; - padding: 20px; - } - - [data-view="dashboard"] .metrics { - margin-bottom: 16px; - } - - [data-view="dashboard"] .metric { - padding: 12px; - } - - .dashboard-global-panel { - padding: 14px; - margin-bottom: 18px; - } - - .dashboard-global-power { - align-items: stretch; - flex-direction: column; - } - - .dashboard-global-power .house-power-actions { - display: grid; - grid-template-columns: 1fr 1fr; - } - - .dashboard-house-climate .house-preset-row { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .dashboard-section-head { - align-items: stretch; - flex-direction: column; - gap: 10px; - } - - .dashboard-section-actions { - justify-content: space-between; - } - - .dashboard-section-actions button { - flex: 1; - } - - .dashboard-group-strip { - grid-template-columns: 1fr; - } - - .dashboard-group-strip .group-preset-row { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .quick-thermostat-control .list-card-head { - align-items: center; - } - - .quick-thermostat-control .zone-quick-actions { - min-width: 116px; - } - - .temporary-presets { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .temporary-active-summary { - align-items: stretch; - flex-direction: column; - } - - .temporary-active-summary .danger { - width: 100%; - white-space: normal; - } -} - -/* Dashboard sections: horizontal tabs, one vertically scrollable panel at a time. */ -.dashboard-tabs { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - margin: 0 0 12px; - padding: 6px; - border: 1px solid var(--line); - border-radius: 17px; - background: var(--surface); -} - -.dashboard-tabs button { - min-width: 0; - min-height: 40px; - padding: 8px 10px; - border: 1px solid transparent; - border-radius: 12px; - color: var(--muted); - background: transparent; - font-size: 12px; - font-weight: 750; -} - -.dashboard-tabs button.active { - border-color: var(--accent-border); - color: var(--accent); - background: var(--accent-soft); -} - -.dashboard-tab-panel[hidden] { - display: none !important; -} - -.dashboard-tab-panel.active { - display: block; - animation: reveal .18s ease; -} - -.dashboard-section-panel { - margin-bottom: 18px; -} - -.dashboard-section-head.compact-head { - align-items: center; - margin-bottom: 12px; -} - -.dashboard-section-head.compact-head h2 { - font-size: 19px; -} - -.dashboard-section-panel>.device-grid, -.dashboard-section-panel>.list-grid, -.dashboard-section-panel>.automation-plan-grid { - margin-bottom: 0; -} - -.notification-alert-types { - display: grid; - gap: 10px; -} - -.notification-alert-types>.field-note { - margin: 0; -} - -.notification-alert-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px 14px; -} - -.notification-alert-grid .check { - min-height: 36px; -} - -@media (max-width:700px) { - .topbar { - gap: 7px; - padding-inline: 10px; - } - - .toolbar-runtime-status { - padding: 6px 8px; - font-size: 10px; - } - - .section-heading { - align-items: stretch; - flex-direction: column; - } - - .section-heading-actions { - display: grid; - grid-template-columns: 1fr 1fr; - width: 100%; - } - - .dashboard-tabs { - gap: 5px; - padding: 5px; - } - - .dashboard-tabs button { - min-height: 42px; - padding: 6px 5px; - font-size: 10px; - line-height: 1.15; - } - - .notification-alert-grid { - grid-template-columns: 1fr; - } -} - -/* 0.6.9 dashboard density and fit fixes */ -[data-view="dashboard"] .dashboard-summary-card { - min-height: 0; - align-items: center; - gap: 14px; - padding: 13px 16px; - border-radius: 18px; -} - -[data-view="dashboard"] .dashboard-summary-card::after { - width: 100px; - height: 100px; - right: -44px; - bottom: -64px; - border-width: 18px; -} - -[data-view="dashboard"] .dashboard-summary-copy { - min-width: 0; -} - -[data-view="dashboard"] .dashboard-summary-copy .eyebrow { - font-size: 9px; -} - -[data-view="dashboard"] .dashboard-summary-copy h1 { - margin: 2px 0 3px; - font-size: 20px; - letter-spacing: -.025em; -} - -[data-view="dashboard"] .dashboard-summary-copy p { - font-size: 11px; - line-height: 1.3; -} - -[data-view="dashboard"] .dashboard-summary-status { - z-index: 1; - display: flex; - align-items: center; - justify-content: flex-end; - min-width: 0; - gap: 10px; -} - -[data-view="dashboard"] .dashboard-summary-status .metrics { - display: flex; - align-items: center; - gap: 5px; - margin: 0; -} - -[data-view="dashboard"] .dashboard-summary-status .metric { - display: flex; - align-items: baseline; - gap: 5px; - min-width: 0; - padding: 6px 8px; - border-radius: 11px; - background: var(--surface-muted); -} - -[data-view="dashboard"] .dashboard-summary-status .metric span { - margin: 0; - font-size: 9px; - white-space: nowrap; -} - -[data-view="dashboard"] .dashboard-summary-status .metric strong { - font-size: 13px; - line-height: 1; -} - -[data-view="dashboard"] .dashboard-summary-status .hero-temp { - min-width: 72px; - font-size: 31px; - letter-spacing: -.045em; - text-align: right; -} - -/* Quick thermostat: target control gets its own row, readings no longer collide with it. */ -#dashboardZones { - grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); -} - -#dashboardZones, -#dashboardDevices { - align-items: stretch; -} - -/* Primary control/configuration cards need room for labels and actions. - Keep at most two cards per row and collapse naturally to one before - the card internals start clipping. */ -#dashboardZones, -#dashboardDevices, -#deviceList, -#zoneList { - grid-template-columns: repeat(auto-fit, minmax(min(100%, 460px), 1fr)); - grid-auto-rows: 1fr; -} - -#dashboardZones > article, -#dashboardDevices > article, -#deviceList > article, -#zoneList > article { - height: 100%; -} - -#deviceList .device-card .card-menu { - flex-wrap: wrap; - justify-content: flex-end; -} - -.quick-thermostat-control { - min-width: 0; -} - -.quick-thermostat-control .thermostat-main { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - grid-template-areas: "control control" "room device"; - gap: 5px 10px; - margin: 8px 0 7px; -} - -.quick-thermostat-control .thermostat-main>div:first-child { - grid-area: room; - min-width: 0; - text-align: left; -} - -.quick-thermostat-control .thermostat-main>.temperature-control { - grid-area: control; -} - -.quick-thermostat-control .thermostat-main>div:last-child { - grid-area: device; - min-width: 0; - text-align: right; -} - -.quick-thermostat-control .thermostat-main small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.quick-thermostat-control .thermostat-main strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; -} - -.quick-thermostat-control .thermostat-main .temperature-control.compact { - gap: 12px; - padding: 0 0 2px; -} - -.quick-thermostat-control .thermostat-main .temperature-control.compact button { - width: 34px; - height: 34px; - font-size: 18px; -} - -.quick-thermostat-control .thermostat-main .target-temp.compact { - min-width: 80px; - font-size: 32px; -} - -.quick-thermostat-control .list-card-head h3 { - font-size: 16px; -} - -.quick-thermostat-control .list-card-head p { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11px; -} - -.quick-thermostat-control .zone-enable-toggle { - flex: 0 0 auto; -} - -.quick-thermostat-control .zone-state-line { - gap: 8px; - font-size: 10px; -} - -.quick-thermostat-control .zone-state-line span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.quick-thermostat-control .control-owner-line { - align-items: flex-start; - flex-wrap: wrap; -} - -.quick-thermostat-control .control-owner-line span { - overflow: visible; - text-overflow: clip; - white-space: normal; -} - -/* Smaller Node-RED/simulator-like plan blocks with explicit group context. */ -#controlPlan.automation-plan-grid { - grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); - gap: 8px; -} - -#controlPlan .plan-card { - min-height: 0; - gap: 7px; - padding: 11px; - border-radius: 14px; -} - -#controlPlan .plan-card-head { - gap: 7px; -} - -#controlPlan .plan-card-head h3 { - margin-top: 1px; - font-size: 15px; - line-height: 1.2; -} - -#controlPlan .plan-card-head .eyebrow { - font-size: 8px; - line-height: 1.25; -} - -#controlPlan .plan-card .badge { - max-width: 100px; - padding: 4px 6px; - overflow: hidden; - font-size: 9px; - text-overflow: ellipsis; - white-space: nowrap; -} - -#controlPlan .plan-card>p { - font-size: 10px; - line-height: 1.3; -} - -#controlPlan .plan-temp { - gap: 5px; -} - -#controlPlan .plan-temp>span { - font-size: 15px; -} - -#controlPlan .plan-temp>strong { - font-size: 21px; -} - -#controlPlan .plan-group-state { - display: flex; - align-items: center; - gap: 5px; - min-width: 0; - color: var(--text-soft); - font-size: 11px; -} - -#controlPlan .plan-group-state strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -#controlPlan .plan-group { - border-color: color-mix(in srgb, var(--accent) 20%, var(--line)); - background: color-mix(in srgb, var(--surface) 97%, var(--accent)); -} - -#controlPlan .plan-events { - gap: 4px; - padding-top: 6px; -} - -#controlPlan .plan-events li { - grid-template-columns: 62px minmax(0, 1fr); - gap: 5px; - font-size: 9px; - line-height: 1.3; -} - -#controlPlan .plan-events li span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -#controlPlan .plan-rules { - gap: 10px; -} - -#controlPlan .plan-rules .plan-card-head { - padding-bottom: 2px; -} - -#controlPlan .plan-rules .plan-events { - gap: 8px; - padding-top: 9px; -} - -#controlPlan .plan-rules .plan-events li { - align-items: start; - gap: 8px; -} - -#controlPlan .plan-events .plan-rule-summary { - display: grid; - gap: 2px; - overflow: visible; - text-overflow: clip; - white-space: normal; -} - -#controlPlan .plan-rule-summary strong { - color: var(--text); - font-size: 10px; - font-weight: 650; - line-height: 1.35; -} - -#controlPlan .plan-rule-summary small { - color: var(--muted); - font-size: 9px; - line-height: 1.35; -} - -#controlPlan .plan-events .muted { - white-space: normal; -} - -@media (max-width: 900px) { - [data-view="dashboard"] .dashboard-summary-card { - align-items: flex-start; - } - - [data-view="dashboard"] .dashboard-summary-status { - flex-wrap: wrap; - } - - [data-view="dashboard"] .dashboard-summary-status .metrics { - order: 2; - width: 100%; - justify-content: flex-end; - } -} - -@media (max-width: 700px) { - [data-view="dashboard"] .dashboard-summary-card { - min-height: 0; - padding: 12px 13px; - } - - [data-view="dashboard"] .dashboard-summary-copy h1 { - font-size: 18px; - } - - [data-view="dashboard"] .dashboard-summary-status { - align-self: center; - } - - [data-view="dashboard"] .dashboard-summary-status .metrics { - display: none; - } - - [data-view="dashboard"] .dashboard-summary-status .hero-temp { - min-width: 64px; - font-size: 27px; - } - - #dashboardZones { - grid-template-columns: 1fr; - } - - #controlPlan.automation-plan-grid { - grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); - } -} - -[data-view="dashboard"] .dashboard-summary-card { - margin-bottom: 10px; -} - -/* 0.7.2: Zones are configuration cards; live controls stay on the Dashboard. */ -.zone-config-card { - display: grid; - gap: 12px; - overflow: hidden; -} - -.zone-config-card.demanding { - border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); -} - -.zone-config-card.zone-disabled .zone-config-status { - opacity: .68; -} - -.zone-config-status { - display: grid; - grid-template-columns: minmax(150px, .8fr) minmax(180px, 1.2fr); - align-items: center; - gap: 12px; - padding: 11px 12px; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface-muted); -} - -.zone-config-temperature>small, -.zone-config-grid small { - display: block; - margin-bottom: 4px; - color: var(--muted); - font-size: 9px; - text-transform: uppercase; - letter-spacing: .06em; -} - -.zone-config-temperature>div { - display: flex; - align-items: baseline; - gap: 7px; -} - -.zone-config-temperature span { - color: var(--muted); - font-size: 18px; -} - -.zone-config-temperature b { - color: var(--muted); - font-size: 11px; -} - -.zone-config-temperature strong { - font-size: 23px; - letter-spacing: -.03em; -} - -.zone-config-runtime { - display: grid; - justify-items: end; - gap: 4px; - min-width: 0; - color: var(--text-soft); - font-size: 11px; - text-align: right; -} - -.zone-config-runtime>span:not(.badge), -.zone-config-runtime small { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.zone-config-runtime small { - color: var(--muted); -} - -.zone-config-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 7px; -} - -.zone-config-grid>div { - min-width: 0; - padding: 9px 10px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--surface-muted); -} - -.zone-config-grid strong { - display: block; - overflow: hidden; - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -#zoneList .zone-config-card .sensor-detail { - margin-top: 0; -} - -#zoneList .zone-config-card .card-footer { - align-items: flex-start; - margin-top: 0; -} - -#zoneList .zone-config-card .card-footer>small { - flex: 1 1 auto; - min-width: 0; - overflow: visible; - text-overflow: clip; - white-space: normal; - line-height: 1.35; -} - -#zoneList .zone-config-card .card-menu { - flex: 0 0 auto; - flex-wrap: wrap; - justify-content: flex-end; -} - -#zoneList .zone-config-card .card-menu .primary { - color: var(--accent-text); - background: var(--accent); -} - -@media (max-width:620px) { - .zone-config-status { - grid-template-columns: 1fr; - } - - .zone-config-runtime { - justify-items: start; - text-align: left; - } - - .zone-config-grid { - grid-template-columns: 1fr; - } - - #zoneList .zone-config-card .card-footer { - flex-direction: column; - } - - #zoneList .zone-config-card .card-menu { - width: 100%; - justify-content: flex-start; - } -} - -/* 0.7.1+: quick thermostats and quick manual control use the shared quick-control classes above. */ - -/* 0.8.11 mobile toolbar and chart inspection UX */ -.topbar { - grid-template-columns: minmax(0, 1fr) auto auto; - column-gap: 10px; -} - -.top-actions { - min-width: 0; -} - -.toolbar-icon-control { - display: inline-grid; - place-items: center; - flex: 0 0 40px; - width: 40px; - height: 40px; - min-height: 40px; - padding: 0; - border: 1px solid var(--line); - border-radius: 11px; - background: var(--surface-muted); - color: var(--text-soft); - font-size: 18px; - line-height: 1; -} - -.toolbar-icon-control:hover { - background: var(--surface-2); -} - -#refreshButton.toolbar-icon-control { - font-size: 21px; -} - -button:focus-visible { - outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); - outline-offset: 2px; -} - -.chart-hover-line { - position: absolute; - z-index: 3; - width: 1px; - background: color-mix(in srgb, var(--text) 40%, transparent); - pointer-events: none; -} - -.chart-tooltip { - position: absolute; - z-index: 5; - min-width: 175px; - max-width: min(290px, calc(100vw - 32px)); - padding: 10px 11px; - border: 1px solid var(--line); - border-radius: 12px; - background: color-mix(in srgb, var(--surface) 96%, transparent); - box-shadow: 0 10px 28px rgba(0, 0, 0, .2); - color: var(--text); - font-size: 11px; - line-height: 1.35; - pointer-events: none; - backdrop-filter: blur(12px); -} - -.chart-tooltip-time { - display: block; - margin-bottom: 7px; - color: var(--text-soft); - font-weight: 800; - font-variant-numeric: tabular-nums; -} - -.chart-tooltip-values { - display: grid; - gap: 5px; -} - -.chart-tooltip-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; -} - -.chart-tooltip-label { - display: inline-flex; - min-width: 0; - align-items: center; - gap: 6px; - color: var(--muted); -} - -.chart-tooltip-label i { - flex: 0 0 7px; - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--tooltip-color); -} - -.chart-tooltip-label span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chart-tooltip-value { - color: var(--text); - font-weight: 800; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.chart-tooltip-sample-time { - color: var(--muted); - font-size: 9px; - font-weight: 600; -} - -@media (max-width: 700px) { - .topbar { - min-height: 64px; - gap: 6px; - padding: 9px 10px; - } - - .brand strong { - font-size: 13px; - } - - .toolbar-runtime-status { - padding: 6px 8px; - font-size: 10px; - } - - .top-actions { - gap: 5px; - } - - .toolbar-icon-control { - flex-basis: 38px; - width: 38px; - height: 38px; - min-height: 38px; - border-radius: 10px; - } - -} - -@media (max-width: 360px) { - .topbar { - grid-template-columns: minmax(0, 1fr) auto; - grid-template-rows: auto auto; - } - - .brand { - grid-column: 1; - grid-row: 1; - } - - .top-actions { - grid-column: 2; - grid-row: 1; - } - - .toolbar-runtime-status { - grid-column: 1 / -1; - grid-row: 2; - justify-self: stretch; - text-align: center; - } -} - -.chart-tooltip-sample-time { - display: block; - margin-top: 1px; -} - -@media (max-width: 700px) { - .top-actions { - width: auto; - } -} - -.chart-hover-hint { - display: block; - margin-top: 5px; - color: var(--muted); - font-size: 10px; -} - -.chart-tooltip-value { - text-align: right; -} - -.chart-wrap canvas:focus-visible { - outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); - outline-offset: -2px; -} - -.toolbar-runtime-status { - min-width: max-content; -} - -/* 0.8.13 settings separation, simulation warning, system status and debug filters */ -.toolbar-picker { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - flex: 0 0 54px; - width: 54px; - height: 40px; - border: 1px solid var(--line); - border-radius: 11px; - background: var(--surface-muted); - color: var(--text-soft); - transition: border-color .15s ease, background .15s ease, transform .15s ease; -} - -.toolbar-picker:hover { - background: var(--surface-2); -} - -.toolbar-picker:active { - transform: scale(.97); -} - -.toolbar-picker:focus-within { - outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); - outline-offset: 2px; -} - -.toolbar-picker-value { - min-width: 18px; - color: var(--text-soft); - font-size: 11px; - font-weight: 850; - letter-spacing: .06em; - line-height: 1; - text-align: center; -} - -.theme-picker-value { - font-size: 16px; - letter-spacing: 0; -} - -.toolbar-picker-chevron { - margin-top: -2px; - color: var(--muted); - font-size: 12px; - line-height: 1; -} - -.toolbar-picker-select { - position: absolute; - inset: 0; - width: 100%; - min-width: 0; - height: 100%; - min-height: 0; - margin: 0; - padding: 0; - border: 0; - opacity: 0; - cursor: pointer; -} - -.connection-banner { - grid-column: 1 / -1; - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - width: 100%; - padding: 9px 11px; - border: 1px solid color-mix(in srgb, var(--danger) 34%, var(--line)); - border-radius: 13px; - background: color-mix(in srgb, var(--danger-soft) 78%, var(--surface)); -} - -.connection-banner[hidden] { - display: none; -} - -.simulation-mode-banner { - grid-column: 1 / -1; - display: flex; - align-items: center; - justify-content: center; - gap: 14px; - width: 100%; - padding: 13px 16px; - border: 2px solid color-mix(in srgb, var(--warning) 62%, var(--line)); - border-radius: 14px; - background: color-mix(in srgb, var(--warning-soft) 90%, var(--surface)); - text-align: center; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--warning) 10%, transparent), 0 5px 18px color-mix(in srgb, var(--warning) 10%, transparent); -} - -.simulation-mode-banner[hidden] { - display: none; -} - -.simulation-mode-banner strong { - color: var(--warning); - font-size: clamp(18px, 2.6vw, 26px); - font-weight: 950; - letter-spacing: .09em; - line-height: 1; - white-space: nowrap; -} - -.simulation-mode-banner span { - max-width: 720px; - color: var(--text-soft); - font-size: 12px; - font-weight: 700; - line-height: 1.35; -} - -.connection-banner-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--danger); - box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 13%, transparent); -} - -.connection-banner>div { - min-width: 0; -} - -.connection-banner strong, -.connection-banner span { - display: block; -} - -.connection-banner strong { - margin-bottom: 1px; - font-size: 11px; -} - -.connection-banner>div span { - overflow: hidden; - color: var(--muted); - font-size: 10px; - line-height: 1.35; - text-overflow: ellipsis; - white-space: nowrap; -} - -.connection-banner button { - min-height: 32px; - padding: 6px 10px; - border-radius: 9px; - font-size: 10px; -} - -body.connection-lost .connection-dot { - box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 13%, transparent); -} - -.field-error { - display: block; - margin-top: -2px; - color: var(--danger); - font-size: 10px; - font-weight: 650; - line-height: 1.35; -} - -.has-error>input, -.has-error>select, -.has-error>textarea, -input[aria-invalid="true"], -select[aria-invalid="true"], -textarea[aria-invalid="true"] { - border-color: color-mix(in srgb, var(--danger) 68%, var(--line)); - outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent); -} - -.form-error-summary { - padding: 10px 12px; - border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line)); - border-radius: 12px; - background: var(--danger-soft); - color: var(--danger); - font-size: 11px; - font-weight: 650; - line-height: 1.4; -} - -.settings-form>.form-error-summary { - margin: 0 0 12px; -} - -.dialog-form>.form-error-summary { - margin-top: -2px; -} - -.settings-form.is-saving, -.dialog-form.is-saving { - cursor: progress; -} - -.button-spinner { - display: inline-block; - width: 12px; - height: 12px; - margin-right: 7px; - border: 2px solid currentColor; - border-right-color: transparent; - border-radius: 50%; - vertical-align: -2px; - animation: button-spin .7s linear infinite; -} - -@keyframes button-spin { - to { - transform: rotate(360deg); - } -} - -.chart-title-row { - align-items: flex-start; -} - -.chart-zoom-controls { - display: inline-grid; - grid-template-columns: 32px minmax(52px, auto) 32px; - flex: 0 0 auto; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface-muted); -} - -.chart-zoom-controls button { - min-width: 0; - height: 30px; - padding: 0 7px; - border-radius: 0; - border-right: 1px solid var(--line); - background: transparent; - color: var(--text-soft); - font-size: 12px; -} - -.chart-zoom-controls button:last-child { - border-right: 0; -} - -.chart-zoom-controls button:hover:not(:disabled) { - background: var(--surface-2); -} - -.chart-zoom-controls button:disabled { - cursor: default; - opacity: .34; -} - -.chart-zoom-reset { - color: var(--muted) !important; - font-size: 10px !important; - font-variant-numeric: tabular-nums; -} - -.chart-wrap { - overscroll-behavior-inline: contain; - scroll-behavior: smooth; -} - -.chart-wrap canvas { - touch-action: pan-y; - user-select: none; - -webkit-user-select: none; -} - -.chart-selection { - position: absolute; - z-index: 4; - border: 1px solid color-mix(in srgb, var(--accent) 76%, transparent); - border-radius: 4px; - background: color-mix(in srgb, var(--accent) 16%, transparent); - pointer-events: none; -} - -@media (hover:hover) and (pointer:fine) { - .history-chart-card .chart-wrap canvas { - cursor: crosshair; - } -} - -@media (max-width: 700px) { - .toolbar-picker { - flex-basis: 38px; - width: 38px; - height: 38px; - border-radius: 10px; - gap: 2px; - } - - .toolbar-picker-value { - min-width: 15px; - font-size: 10px; - } - - .theme-picker-value { - font-size: 14px; - } - - .toolbar-picker-chevron { - font-size: 9px; - } - - .connection-banner { - grid-template-columns: auto minmax(0, 1fr) auto; - padding: 8px 9px; - } - - .connection-banner>div span { - white-space: normal; - } - - .chart-title-row { - align-items: stretch; - flex-direction: column; - } - - .chart-zoom-controls { - align-self: flex-end; - } -} - -@media (max-width: 360px) { - .connection-banner { - grid-column: 1 / -1; - } -} - -@media (max-width: 460px) { - .topbar { - grid-template-columns: minmax(0, 1fr) auto; - grid-template-rows: auto auto; - } - - .brand { - grid-column: 1; - grid-row: 1; - } - - .top-actions { - grid-column: 2; - grid-row: 1; - } - - .toolbar-runtime-status { - grid-column: 1 / -1; - grid-row: 2; - justify-self: stretch; - overflow: hidden; - text-align: center; - text-overflow: ellipsis; - } - - .connection-banner { - grid-column: 1 / -1; - grid-row: auto; - } -} - -.settings-form.has-unsaved-changes .settings-save-bar { - border-color: color-mix(in srgb, var(--accent) 48%, var(--line-strong)); - box-shadow: 0 8px 30px rgba(0, 0, 0, .18), 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); -} - -/* Standalone HTTP error pages */ -.error-page { - min-height: 100dvh; -} - -.error-topbar { - grid-template-columns: 1fr; -} - -.error-main { - display: grid; - place-items: center; - min-height: calc(100dvh - 72px); - padding: 32px 18px max(48px, env(safe-area-inset-bottom)); -} - -.error-card { - width: min(620px, 100%); - padding: clamp(28px, 7vw, 52px); - border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--line)); - border-radius: 30px; - background: var(--surface); - box-shadow: 0 24px 80px rgba(0, 0, 0, .18); -} - -.error-code { - margin: 12px 0 -4px; - color: var(--accent); - font-size: clamp(72px, 20vw, 132px); - font-weight: 250; - line-height: .92; - letter-spacing: -.075em; -} - -.error-card h1 { - margin-bottom: 10px; -} - -.error-card .lead { - margin-bottom: 0; -} - -.error-actions { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin-top: 28px; -} - -.button-link { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 42px; - padding: 11px 15px; - border-radius: 13px; - text-decoration: none; - transition: transform .15s ease, background .15s ease, opacity .15s ease; -} - -.button-link:active { - transform: scale(.97); -} - -.button-link.primary { - background: var(--accent); - color: var(--accent-text); - font-weight: 800; -} - -.button-link.primary:hover { - background: var(--accent-strong); -} - -/* Explicit control-source states */ -.list-card.group-controlled { - border-color: var(--group-control-color); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--group-control-color) 30%, transparent); -} - -.group-control-badge, -.group-control-tag { - display: inline-flex; - align-items: center; - width: max-content; - max-width: 100%; - padding: 4px 7px; - border-radius: 999px; - color: var(--group-control-color); - background: color-mix(in srgb, var(--group-control-color) 12%, var(--surface)); - font-size: 10px; - font-weight: 800; - line-height: 1.1; -} - -.group-control-tag { - margin-left: 5px; - vertical-align: middle; -} - -.group-preset-row button.editing:not(.active) { - border-color: color-mix(in srgb, var(--accent) 55%, var(--line)); - color: var(--accent); - background: color-mix(in srgb, var(--accent) 8%, var(--surface)); -} - -.zone-thermostat.lockout-waiting:not(.group-controlled), -.zone-config-card.lockout-waiting:not(.group-controlled) { - border-color: color-mix(in srgb, var(--warning) 58%, var(--line)); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--warning) 16%, transparent); -} - -.zone-thermostat.lockout-waiting, -.zone-config-card.lockout-waiting { - background-image: linear-gradient(color-mix(in srgb, var(--warning-soft) 42%, transparent), color-mix(in srgb, var(--warning-soft) 42%, transparent)); -} - -/* v0.8.20: group ownership and pending thermostat state clarity. */ -.group-custom-temperature-row { - grid-template-columns: minmax(0, 1fr) auto auto auto; -} - -.group-custom-draft-note { - display: block; - margin-top: 6px; - color: var(--warning); - font-size: 10px; - line-height: 1.35; -} - -.zone-thermostat.lockout-waiting, -.zone-config-card.lockout-waiting { - background-image: linear-gradient(color-mix(in srgb, var(--warning-soft) 48%, transparent), color-mix(in srgb, var(--warning-soft) 48%, transparent)); -} - -.zone-thermostat.group-controlled.lockout-waiting, -.zone-config-card.group-controlled.lockout-waiting { - border-color: var(--group-control-color); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--group-control-color) 30%, transparent), inset 0 0 0 2px color-mix(in srgb, var(--warning) 18%, transparent); -} - -@media (max-width:520px) { - .group-custom-temperature-row { - grid-template-columns: minmax(0, 1fr) auto; - } - - .group-custom-temperature-row input { - grid-column: 1; - } - - .group-custom-temperature-row>span { - grid-column: 2; - } - - .group-custom-temperature-row button { - grid-column: auto; - } -} - -/* Idle groups keep the card neutral; only the state badge carries the group color. */ -.list-card.group-card.group-linked, -.list-card.group-card.group-off { - border-style: solid; - border-color: var(--line); - box-shadow: none; -} - -.group-on-badge, -.group-off-badge { - color: var(--group-control-color); - border: 1px solid color-mix(in srgb, var(--group-control-color) 58%, var(--line)); - background: color-mix(in srgb, var(--group-control-color) 7%, var(--surface)); -} - -.group-off-badge { - border-style: dashed; -} - -/* Color the whole group card only while the group is the active control source. */ -.list-card.group-card.group-linked.group-controlled { - border-style: solid; - border-color: var(--group-control-color); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--group-control-color) 30%, transparent); -} - -/* v0.9.0: visible/cancellable compressor-protection queue. */ -.compressor-queue-panel { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 10px; - padding: 10px 12px; - border: 1px dashed var(--warning, #b7791f); - border-radius: 12px; - background: color-mix(in srgb, var(--warning, #b7791f) 9%, transparent); -} - -.compressor-queue-panel>div { - display: grid; - gap: 2px; - min-width: 0; -} - -.compressor-queue-panel strong { - font-size: .88rem; -} - -.compressor-queue-panel span, -.compressor-queue-panel small { - overflow-wrap: anywhere; -} - -.compressor-queue-panel button { - flex: 0 0 auto; -} - -/* v0.9.1: global compressor queue overview. */ -.queue-overview-button { - display: inline-flex; - align-items: center; - gap: 7px; -} - -.queue-overview-count { - display: inline-grid; - place-items: center; - min-width: 23px; - height: 23px; - padding: 0 6px; - border-radius: 999px; - background: color-mix(in srgb, var(--text) 9%, transparent); - font-size: .78rem; -} - -.queue-overview-button.has-items { - border-color: var(--warning, #b7791f); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--warning, #b7791f) 22%, transparent); -} - -.queue-overview-button.has-items .queue-overview-count { - background: color-mix(in srgb, var(--warning, #b7791f) 18%, transparent); - color: var(--text); -} - -.compressor-queue-dialog { - width: min(780px, calc(100% - 24px)); -} - -.compressor-queue-dialog-body { - gap: 16px; -} - -.compressor-queue-summary { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 13px; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface); -} - -.compressor-queue-summary span { - color: var(--muted); - font-size: .86rem; -} - -.compressor-queue-summary strong { - font-size: 1.15rem; -} - -.compressor-queue-list { - display: grid; - gap: 10px; -} - -.compressor-queue-item { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - padding: 14px; - border: 1px solid var(--line); - border-radius: 16px; - background: linear-gradient(135deg, color-mix(in srgb, var(--warning, #b7791f) 7%, var(--surface)), var(--surface)); -} - -.compressor-queue-index { - display: grid; - place-items: center; - width: 32px; - height: 32px; - border-radius: 10px; - background: color-mix(in srgb, var(--warning, #b7791f) 15%, transparent); - font-weight: 800; -} - -.compressor-queue-item-main { - min-width: 0; - display: grid; - gap: 8px; -} - -.compressor-queue-item-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; -} - -.compressor-queue-item-head h3 { - margin: 2px 0 0; - font-size: 1rem; -} - -.compressor-queue-reason { - flex: 0 0 auto; - padding: 5px 8px; - border-radius: 999px; - background: color-mix(in srgb, var(--warning, #b7791f) 14%, transparent); - font-size: .73rem; - font-weight: 700; -} - -.compressor-queue-command { - margin: 0; - font-weight: 700; - overflow-wrap: anywhere; -} - -.compressor-queue-meta { - display: flex; - flex-wrap: wrap; - gap: 6px 12px; - color: var(--muted); - font-size: .78rem; -} - -.compressor-queue-meta strong { - color: var(--text); - font-weight: 700; -} - -.compressor-queue-cancel { - align-self: center; -} - -.compressor-queue-empty { - display: grid; - justify-items: center; - gap: 7px; - padding: 30px 18px; - text-align: center; - border: 1px dashed var(--line); - border-radius: 16px; - color: var(--muted); -} - -.compressor-queue-empty>span { - display: grid; - place-items: center; - width: 42px; - height: 42px; - border-radius: 999px; - background: var(--surface); - font-size: 1.35rem; - color: var(--text); -} - -.compressor-queue-empty strong { - color: var(--text); - font-size: 1rem; -} - -.compressor-queue-empty p { - margin: 0; - max-width: 440px; -} - -.compressor-queue-actions { - position: sticky; - bottom: 0; - padding-top: 10px; - background: linear-gradient(transparent, var(--dialog-bg) 28%); -} - -@media (max-width:620px) { - .compressor-queue-item { - grid-template-columns: auto minmax(0, 1fr); - } - - .compressor-queue-cancel { - grid-column: 1 / -1; - width: 100%; - } - - .compressor-queue-item-head { - display: grid; - } - - .compressor-queue-reason { - justify-self: start; - } -} -/* Visual Flow editor */ -.flow-info-panel { display:flex; align-items:center; justify-content:space-between; gap:20px; } -.flow-info-panel p { margin:.35rem 0 0; color:var(--muted); max-width:780px; } -.flow-card .card-footer small { max-width:70%; } -body.flow-editor-open { overflow:hidden; } -.flow-editor { position:fixed; inset:0; z-index:80; background:var(--bg); display:flex; flex-direction:column; } -.flow-editor[hidden] { display:none !important; } -.flow-editor-bar { min-height:76px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:10px 18px; border-bottom:1px solid var(--line); background:var(--surface); } -.flow-editor-title { display:flex; align-items:center; gap:12px; min-width:0; } -.flow-editor-title > div { display:grid; gap:2px; min-width:0; } -.flow-name-wrap { min-width:280px; min-height:36px; display:flex; align-items:center; gap:8px; } -.flow-name-view { display:flex; align-items:center; gap:6px; min-width:0; } -.flow-name-view[hidden] { display:none !important; } -.flow-name-text { appearance:none; border:0; background:transparent; color:var(--text); font:inherit; font-weight:750; font-size:1.12rem; line-height:1.25; padding:4px 2px; min-width:0; max-width:min(52vw,620px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:text; text-align:left; } -.flow-name-text:hover { color:var(--accent); } -.flow-name-edit { appearance:none; border:0; background:transparent; color:var(--muted); width:28px; height:28px; border-radius:7px; padding:0; cursor:pointer; display:grid; place-items:center; opacity:.62; transition:opacity .15s ease,background .15s ease,color .15s ease; } -.flow-name-view:hover .flow-name-edit,.flow-name-edit:focus-visible { opacity:1; color:var(--accent); background:var(--accent-soft); outline:none; } -.flow-editor-title input { border:1px solid var(--line-strong); background:var(--input-bg); color:var(--text); font-weight:750; font-size:1.12rem; padding:7px 10px; min-width:280px; outline:none; border-radius:9px; box-shadow:inset 0 0 0 1px transparent; transition:border-color .15s ease,box-shadow .15s ease,background .15s ease; } -.flow-editor-title input[hidden] { display:none !important; } -.flow-editor-title input:hover { border-color:var(--accent-border); background:var(--surface-2); } -.flow-editor-title input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); background:var(--input-bg); } -.flow-draft-badge { border-color:var(--orange); background:color-mix(in srgb,var(--orange) 14%,var(--surface)); color:var(--text); } -.flow-card.is-draft { border-style:dashed; } -.flow-editor-actions { display:flex; align-items:center; gap:10px; } -.flow-enabled { display:flex; align-items:center; gap:8px; color:var(--muted); } -.flow-editor-body { flex:1; min-height:0; display:grid; grid-template-columns:220px minmax(420px,1fr) 300px; } -.flow-palette,.flow-inspector { background:var(--surface); overflow:auto; padding:16px; } -.flow-palette { border-right:1px solid var(--line); } -.flow-inspector { border-left:1px solid var(--line); } -.flow-palette-head { display:grid; gap:4px; margin-bottom:18px; } -.flow-palette-head small { color:var(--muted); line-height:1.35; } -.flow-palette-group { display:grid; gap:7px; margin:0 0 18px; } -.flow-palette-group > span { color:var(--muted); font-size:.72rem; font-weight:750; text-transform:uppercase; letter-spacing:.08em; margin-bottom:2px; } -.flow-palette-group button { text-align:left; border:1px solid var(--line); background:var(--surface-2); color:var(--text); border-radius:10px; padding:9px 10px; } -.flow-palette-group button:hover { border-color:var(--accent-border); background:var(--accent-soft); } -.flow-workspace-wrap { min-width:0; min-height:0; display:grid; grid-template-rows:auto minmax(0,1fr) auto; background:var(--canvas); } -.flow-workspace { position:relative; min-height:0; overflow:auto; outline:none; background-color:var(--canvas); background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px); background-size:24px 24px; } -.flow-nodes { position:relative; width:2400px; height:1500px; } -.flow-edges { position:absolute; inset:0; width:2400px; height:1500px; overflow:visible; pointer-events:none; z-index:1; } -.flow-edges path { fill:none; stroke:var(--muted-2); stroke-width:2.5; pointer-events:stroke; cursor:pointer; } -.flow-edges path:hover { stroke:var(--danger); stroke-width:4; } -.flow-editor .flow-node { position:absolute; z-index:2; display:block; align-content:normal; gap:0; width:170px; min-height:78px; padding:0; border:1px solid var(--line-strong); border-radius:12px; background:var(--surface); box-shadow:0 10px 28px rgba(0,0,0,.18); user-select:none; } -.flow-editor .flow-node.selected { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft),0 12px 30px rgba(0,0,0,.22); } -.flow-editor .flow-node-time { border-top:3px solid var(--blue); } -.flow-editor .flow-node-sensor { border-top:3px solid var(--teal); } -.flow-editor .flow-node-logic { border-top:3px solid var(--purple); } -.flow-editor .flow-node-action { border-top:3px solid var(--orange); } -.flow-editor .flow-node-head { display:flex; justify-content:space-between; gap:8px; align-items:center; padding:8px 9px 6px; font-size:.82rem; font-weight:750; cursor:grab; } -.flow-editor .flow-node-head button { border:0; background:transparent; color:var(--muted); padding:0 2px; font-size:1rem; } -.flow-editor .flow-node-body { padding:5px 10px 10px; color:var(--muted); font-size:.76rem; line-height:1.35; overflow-wrap:anywhere; } -.flow-editor .flow-port { position:absolute; top:50%; width:14px; height:14px; margin-top:-7px; border-radius:50%; border:2px solid var(--surface); background:var(--muted-2); padding:0; z-index:4; } -.flow-editor .flow-port-in { left:-8px; } -.flow-editor .flow-port-out { right:-8px; } -.flow-editor .flow-port:hover,.flow-editor .flow-port.armed { background:var(--accent); transform:scale(1.15); } -.flow-empty-hint { position:absolute; z-index:0; left:50%; top:42%; transform:translate(-50%,-50%); text-align:center; display:grid; gap:5px; color:var(--muted); pointer-events:none; } -.flow-natural-preview { display:flex; justify-content:space-between; gap:24px; align-items:center; min-height:58px; padding:10px 18px; border-top:1px solid var(--line); background:var(--surface); } -.flow-preview-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:baseline; min-width:0; } -.flow-preview-interpretation { flex:1 1 auto; } -.flow-preview-runtime { flex:0 0 auto; justify-content:end; text-align:right; } -.flow-natural-preview span { color:var(--muted); font-size:.86rem; line-height:1.4; } -.flow-preview-runtime span { white-space:nowrap; } -.flow-inspector-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-bottom:18px; } -.flow-inspector-head h3 { margin:2px 0 0; } -.flow-inspector label { display:grid; gap:6px; margin-bottom:13px; color:var(--muted); font-size:.82rem; } -.flow-inspector input,.flow-inspector select { width:100%; border:1px solid var(--line); border-radius:10px; background:var(--input-bg); color:var(--text); padding:9px 10px; } -.flow-inspector .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; } -.flow-day-grid { display:grid; grid-template-columns:1fr 1fr; gap:7px; } -.flow-day-grid label { display:flex; align-items:center; gap:7px; padding:8px; border:1px solid var(--line); border-radius:9px; margin:0; } -.flow-day-grid input { width:auto; } -.flow-inspector-meta { display:grid; gap:5px; margin-top:20px; padding-top:14px; border-top:1px solid var(--line); color:var(--muted); } -.flow-inspector-meta code { font-size:.72rem; overflow-wrap:anywhere; } - -@media (max-width: 980px) { - .flow-editor-body { grid-template-columns:180px minmax(360px,1fr) 260px; } - .flow-name-wrap,.flow-editor-title input { min-width:180px; } -} -@media (max-width: 760px) { - .flow-editor-bar { align-items:flex-start; flex-direction:column; } - .flow-editor-actions { width:100%; justify-content:flex-end; } - .flow-editor-body { grid-template-columns:132px minmax(500px,1fr); } - .flow-inspector { position:absolute; right:0; top:138px; bottom:0; width:min(310px,86vw); z-index:8; box-shadow:-12px 0 30px rgba(0,0,0,.28); } - .flow-palette { padding:10px; } - .flow-palette-group button { font-size:.76rem; padding:8px; } - .flow-natural-preview { flex-direction:column; align-items:stretch; gap:6px; } - .flow-preview-item { grid-template-columns:1fr; gap:2px; } - .flow-preview-runtime { justify-content:start; text-align:left; } - .flow-preview-runtime span { white-space:normal; } -} - -/* Flow diagnostics, templates and portable Flow files */ -.flow-template-card { display:grid; gap:7px; text-align:left; align-content:start; min-height:118px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); color:var(--text); padding:14px; } -.flow-template-card:hover { border-color:var(--accent); } -.flow-template-card span { color:var(--muted); font-size:.86rem; line-height:1.4; } -.flow-simulation-controls { display:grid; gap:14px; padding:4px 0 16px; border-bottom:1px solid var(--line); } -.flow-simulation-overrides { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-top:8px; } -.flow-test-results { display:grid; gap:10px; margin-top:16px; } -.flow-test-summary,.flow-test-action,.flow-log-row { border:1px solid var(--line); border-radius:12px; background:var(--surface-2); padding:12px; } -.flow-test-summary { display:flex; justify-content:space-between; gap:12px; align-items:center; } -.flow-test-action > div:first-child,.flow-log-row > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; } -.flow-test-action.pass { border-left:4px solid var(--teal); } -.flow-test-action.blocked { border-left:4px solid var(--line-strong); } -.flow-trace { display:grid; gap:5px; margin-top:10px; } -.flow-trace > div { display:flex; justify-content:space-between; gap:10px; padding-top:5px; border-top:1px solid var(--line); font-size:.82rem; } -.flow-trace code { color:var(--muted); max-width:48%; overflow-wrap:anywhere; text-align:right; } -.flow-log-row p { margin:8px 0 4px; } -.flow-log-row small { color:var(--muted); } -@media (max-width:760px) { .flow-test-summary { align-items:flex-start; flex-direction:column; } } -.flow-editor-actions { flex-wrap:wrap; justify-content:flex-end; } -.flow-editor-actions .secondary { padding-inline:10px; } -@media (max-width:1100px) { - .flow-editor-bar { align-items:flex-start; } - .flow-editor-actions { max-width:70%; } -} -@media (max-width:760px) { - .flow-editor-actions { max-width:none; justify-content:flex-start; } -} -.flow-device-options { margin:10px 0; padding:10px 12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); } -.flow-device-options summary { cursor:pointer; font-weight:650; } -.flow-device-options[open] summary { margin-bottom:10px; } - -/* Flow list, multi-select and reusable inputs */ -.flow-page-actions { display:flex; align-items:center; gap:10px; } -.flow-list-section { margin-top:22px; padding-top:20px; border-top:1px solid var(--line); } -.flow-list-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; } -.flow-list-heading h2 { margin:3px 0 0; } -.flow-list-heading-count { justify-content:flex-end; } -.flow-card.is-disabled { opacity:.72; } -.flow-card.is-disabled:hover { opacity:.9; } -.flow-workspace-toolbar { min-height:44px; display:flex; align-items:center; gap:7px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); } -.flow-workspace-toolbar .secondary { padding:7px 9px; font-size:.78rem; } -.flow-workspace-toolbar .badge { margin-left:2px; } -.flow-shared-input-list { display:grid; gap:10px; } -.flow-shared-input-row { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:12px 14px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); } -.flow-shared-input-row > div:first-child { min-width:0; display:grid; gap:4px; } -.flow-shared-input-row small,.flow-shared-input-row code { color:var(--muted); overflow-wrap:anywhere; } -.flow-shared-input-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; } -.flow-shared-input-fields { display:grid; gap:12px; } -.flow-shared-input-fields .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; } -.flow-template-list { display:grid; grid-template-columns:1fr; gap:22px; } -.flow-template-category-head { display:grid; gap:3px; } -.flow-template-category-head span { color:var(--muted); font-size:.84rem; } -.flow-template-category-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; } -@media (max-width:760px) { - .flow-list-heading { align-items:flex-start; } - .flow-shared-input-row { align-items:flex-start; flex-direction:column; } - .flow-shared-input-actions { width:100%; } - .flow-shared-input-actions button { flex:1; } - .flow-shared-input-fields .two { grid-template-columns:1fr; } -} - -/* v0.9.9: Flow shared-input navigation and preset category tabs */ -.flow-shared-settings-head { justify-content:space-between; align-items:flex-start; } -.flow-shared-settings-head > div { min-width:0; } -.flow-shared-add-button { flex:0 0 auto; align-self:flex-start; margin-left:auto; } -.flow-shared-settings-link { margin-top:12px; width:max-content; max-width:100%; } -.flow-shared-settings.is-linked-target { outline:2px solid var(--accent); outline-offset:3px; } -.flow-template-tabs { display:flex; gap:8px; overflow-x:auto; padding:4px 0 10px; margin-top:14px; border-bottom:1px solid var(--line); scrollbar-width:thin; } -.flow-template-tab { flex:0 0 auto; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); padding:8px 12px; white-space:nowrap; } -.flow-template-tab.active { border-color:var(--accent-border); background:var(--accent-soft); color:var(--accent); } -.flow-template-list { margin-top:14px; } -.flow-template-category-single { display:grid; gap:12px; } -.flow-template-category-single .flow-template-category-head { padding-bottom:2px; } -@media (max-width:760px) { - .flow-shared-settings-head { gap:12px; } - .flow-shared-add-button { width:auto; } -} - -/* v0.9.10: Flow preset library preview, favorites/search and shared-input diagnostics */ -.flow-shared-usage { display:grid; gap:5px; margin-top:5px; } -.flow-shared-usage > div { display:flex; flex-wrap:wrap; align-items:center; gap:6px; } -.flow-shared-usage .link-button { border:0; padding:0; background:transparent; color:var(--accent); text-align:left; text-decoration:underline; text-underline-offset:2px; cursor:pointer; font:inherit; font-size:.78rem; } -.flow-shared-unused { margin-top:4px; } -.flow-shared-test-panel { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px 14px; align-items:start; padding:12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); } -.flow-shared-test-panel .field-note { margin:3px 0 0; } -.flow-shared-test-result { grid-column:1 / -1; display:grid; gap:8px; padding:10px 12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); } -.flow-shared-test-result.pass { border-left:4px solid var(--teal); } -.flow-shared-test-result.fail { border-left:4px solid var(--line-strong); } -.flow-shared-test-result > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; } -.flow-shared-test-result dl { display:grid; gap:5px; margin:0; } -.flow-shared-test-result dl > div { display:grid; grid-template-columns:120px minmax(0,1fr); gap:8px; } -.flow-shared-test-result dt { color:var(--muted); } -.flow-shared-test-result dd { margin:0; min-width:0; overflow-wrap:anywhere; } -.flow-shared-test-result > small { color:var(--muted); } - -#flowTemplateDialog { width:min(1180px,calc(100vw - 32px)); } -.flow-template-toolbar { margin-top:14px; } -.flow-template-toolbar label { display:grid; gap:6px; } -.flow-template-toolbar input { width:100%; border:1px solid var(--line); border-radius:10px; background:var(--input-bg); color:var(--text); padding:10px 12px; } -.flow-template-browser { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(320px,.85fr); gap:18px; align-items:start; } -.flow-template-catalog { min-width:0; } -.flow-template-preview { position:sticky; top:0; display:grid; gap:12px; margin-top:14px; padding:14px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); min-height:250px; } -.flow-template-preview-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; } -.flow-template-preview-head h3 { margin:3px 0 0; } -.flow-template-preview > p { margin:0; color:var(--muted); line-height:1.45; } -.flow-template-preview-stats { display:flex; flex-wrap:wrap; gap:7px; } -.flow-template-preview-stats span { padding:5px 8px; border:1px solid var(--line); border-radius:999px; color:var(--muted); font-size:.78rem; } -.flow-template-preview-canvas { position:relative; height:250px; overflow:hidden; border:1px solid var(--line); border-radius:12px; background:var(--surface); } -.flow-template-preview-canvas svg { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; } -.flow-template-preview-canvas line { stroke:var(--line-strong); stroke-width:.7; vector-effect:non-scaling-stroke; } -.flow-template-preview-node { position:absolute; transform:translate(-50%,-50%); width:104px; max-width:28%; padding:6px 7px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); box-shadow:0 3px 10px rgba(0,0,0,.12); font-size:.68rem; line-height:1.2; text-align:center; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.flow-template-preview-node-sensor { border-top:3px solid var(--blue, var(--accent)); } -.flow-template-preview-node-logic { border-top:3px solid var(--accent); } -.flow-template-preview-node-action { border-top:3px solid var(--teal); } -.flow-template-requirements { display:grid; gap:8px; } -.flow-template-requirements > div { display:flex; flex-wrap:wrap; gap:6px; } -.flow-template-requirement { padding:5px 8px; border:1px solid var(--line); border-radius:999px; font-size:.75rem; } -.flow-template-requirement.ok { background:var(--surface); } -.flow-template-requirement.missing { border-color:var(--danger, var(--line-strong)); } -.flow-template-requirement.info { color:var(--muted); } -.flow-template-card { position:relative; display:grid; min-height:0; padding:0; overflow:hidden; } -.flow-template-card.selected { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent-border); } -.flow-template-card-main { display:grid; gap:7px; width:100%; min-height:118px; border:0; background:transparent; color:var(--text); padding:14px 42px 14px 14px; text-align:left; align-content:start; cursor:pointer; } -.flow-template-card-main span { color:var(--muted); font-size:.86rem; line-height:1.4; } -.flow-template-favorite { width:26px; height:26px; display:grid; place-items:center; border:0; padding:0; background:transparent; color:var(--muted); cursor:pointer; font-size:1.22rem; line-height:1; opacity:.72; transition:color .15s ease, opacity .15s ease, transform .15s ease; } -.flow-template-card > .flow-template-favorite { position:absolute; right:8px; top:8px; } -.flow-template-favorite:hover { color:var(--text); background:transparent; opacity:1; transform:scale(1.08); } -.flow-template-favorite:focus-visible { outline:2px solid var(--accent); outline-offset:2px; opacity:1; } -.flow-template-favorite.active { color:var(--accent); background:transparent; opacity:1; } -.flow-template-favorite.active:hover { background:transparent; } -.flow-template-favorite:active { transform:scale(.92); } -.flow-template-preview .form-actions { margin-top:0; padding-top:10px; border-top:1px solid var(--line); } - -@media (max-width:900px) { - .flow-template-browser { grid-template-columns:1fr; } - .flow-template-preview { position:static; } -} -@media (max-width:760px) { - .flow-shared-test-panel { grid-template-columns:1fr; } - .flow-shared-test-panel > button { width:100%; } - .flow-shared-test-result { grid-column:1; } - .flow-shared-test-result dl > div { grid-template-columns:1fr; gap:2px; } - .flow-template-preview-canvas { height:220px; } -} - -/* Keep zone hysteresis/source controls aligned without stretching selects. */ -.zone-hysteresis-row { align-items:start; } -.flow-editor .flow-node-current { margin-top:6px; padding-top:6px; border-top:1px solid color-mix(in srgb, var(--border) 72%, transparent); display:flex; align-items:baseline; justify-content:space-between; gap:8px; } -.flow-editor .flow-node-current span { color:var(--muted); font-size:.68rem; white-space:nowrap; } -.flow-editor .flow-node-current strong { min-width:0; max-width:72px; color:var(--text); font-size:.76rem; font-weight:700; text-align:right; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } - -/* Compact, category-coded Flow palette */ -.flow-palette-group { padding-left:8px; border-left:3px solid var(--line-strong); } -.flow-palette-group button { min-height:30px; padding:6px 8px; border-radius:8px; font-size:.76rem; line-height:1.15; } -.flow-palette-trigger { border-left-color:var(--warning); } -.flow-palette-time { border-left-color:var(--blue); } -.flow-palette-timeop { border-left-color:var(--info); } -.flow-palette-sensor { border-left-color:var(--teal); } -.flow-palette-logic { border-left-color:var(--purple); } -.flow-palette-action { border-left-color:var(--orange); } -.flow-palette-haaction { border-left-color:var(--accent); } -.flow-editor .flow-node-trigger { border-top:3px solid var(--warning); } -.flow-editor .flow-node-timeop { border-top:3px solid var(--info); } -.flow-editor .flow-node-haaction { border-top:3px solid var(--accent); } -.flow-template-preview-node-trigger { border-top:3px solid var(--warning); } -.flow-template-preview-node-time, .flow-template-preview-node-timeop { border-top:3px solid var(--blue); } -.flow-template-preview-node-haaction { border-top:3px solid var(--accent); } - -/* Compact Flow state control and mobile action entry point. */ -.flow-enabled { white-space:nowrap; } -.flow-enabled input { width:16px; min-height:16px; accent-color:var(--accent); } -.flow-mobile-actions-button { display:none; } -.flow-inspector-head-actions { display:flex; align-items:center; gap:7px; } -.flow-inspector-close { display:none; width:34px; height:34px; padding:0; font-size:1.1rem; } - -/* Clarify which HA aliases are metric sources and which are only Flow references. */ -.sensor-alias-entity { min-width:0; display:flex; align-items:center; gap:7px; } -.sensor-alias-entity > .mono { min-width:0; overflow:hidden; color:var(--muted); font-size:11px; text-overflow:ellipsis; white-space:nowrap; } -.sensor-source-badges { display:flex; flex:0 0 auto; gap:4px; } -.sensor-source-badge { display:inline-flex; align-items:center; min-height:20px; padding:2px 6px; border:1px solid var(--line); border-radius:999px; font-size:.64rem; font-weight:750; line-height:1; white-space:nowrap; } -.sensor-source-badge.metrics { border-color:var(--accent-border); background:var(--accent-soft); color:var(--accent); } -.sensor-source-badge.flow { background:var(--surface); color:var(--muted); } - -/* UX refresh: clearer hierarchy, safer destructive actions and touch-first layouts. */ -:root { --radius-small:10px; --radius-medium:14px; } - -.device-card,.panel,.list-card { border-radius:var(--radius); } -button { border-radius:11px; } -.metric { border-radius:14px; } -.lead { max-width:780px; line-height:1.55; } -.section-heading h1 { margin-bottom:0; } - -.automation-tabs { - display:flex; align-items:center; gap:5px; width:max-content; max-width:100%; - margin:-2px 0 14px; padding:5px; overflow-x:auto; border:1px solid var(--line); - border-radius:14px; background:var(--surface); scrollbar-width:none; -} -.automation-tabs::-webkit-scrollbar { display:none; } -.automation-tabs button { flex:0 0 auto; min-height:38px; padding:8px 12px; color:var(--muted); background:transparent; font-size:12px; font-weight:750; } -.automation-tabs button.active { color:var(--accent); background:var(--accent-soft); box-shadow:inset 0 0 0 1px var(--accent-border); } - -.mobile-nav-only { display:block; } -.desktop-nav-only { display:none; } -.nav-section-label,.menu-section-label { color:var(--muted); font-size:9px; font-weight:800; letter-spacing:.09em; text-transform:uppercase; } -.menu-section-label { display:block; padding:14px 9px 5px; } -.more-menu-list .menu-section-label:first-child { padding-top:5px; } - -.card-menu { align-items:center; } -.card-primary-action { font-weight:700; } -.card-overflow { position:relative; flex:0 0 auto; } -.card-overflow summary { - display:grid; place-items:center; width:38px; height:38px; border:1px solid var(--line); - border-radius:10px; color:var(--muted); background:var(--surface-muted); cursor:pointer; - list-style:none; font-size:16px; font-weight:800; letter-spacing:.08em; -} -.card-overflow summary::-webkit-details-marker { display:none; } -.card-overflow[open] summary { color:var(--text); border-color:var(--line-strong); background:var(--surface-2); } -.card-overflow-menu { - position:absolute; z-index:25; right:0; bottom:calc(100% + 7px); min-width:150px; padding:6px; - border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:0 14px 34px rgba(0,0,0,.28); -} -.card-overflow-menu button { width:100%; text-align:left; } -.card-overflow-menu .danger { background:var(--danger-soft); } - -.flow-save-status { display:inline-flex; align-items:center; min-height:24px; color:var(--muted); font-size:.72rem; font-weight:750; white-space:nowrap; } -.flow-save-status::before { content:""; width:6px; height:6px; margin-right:6px; border-radius:50%; background:var(--accent); } -.flow-save-status.is-dirty { color:var(--warning); } -.flow-save-status.is-dirty::before { background:var(--warning); } -.flow-canvas { position:relative; width:2400px; height:1500px; transform-origin:0 0; } -.flow-canvas .flow-nodes,.flow-canvas .flow-edges { width:2400px; height:1500px; } -.flow-workspace-toolbar { justify-content:space-between; } -.flow-workspace-primary-actions,.flow-zoom-controls { display:flex; align-items:center; gap:7px; min-width:0; } -.flow-add-block-button { display:none; align-items:center; justify-content:center; gap:5px; } -.flow-zoom-controls .icon-button { width:32px; height:32px; min-height:32px; padding:0; font-size:17px; } -.flow-zoom-controls > span { min-width:44px; color:var(--muted); font-size:.72rem; font-weight:800; text-align:center; font-variant-numeric:tabular-nums; } -.flow-fit-button { min-height:32px; padding:6px 9px; font-size:.75rem; } -.flow-inspector-expand { display:none; width:34px; height:34px; padding:0; } - -.flow-block-dialog { width:min(620px,calc(100% - 24px)); } -.flow-block-dialog .dialog-head { padding:16px 16px 8px; } -.flow-block-search { padding:4px 16px 10px; } -.flow-block-library { display:grid; gap:14px; padding:0 16px 18px; } -.flow-block-library-group { padding-left:10px; border-left:3px solid var(--line-strong); } -.flow-block-library-group.flow-palette-trigger { border-left-color:var(--warning); } -.flow-block-library-group.flow-palette-time { border-left-color:var(--blue); } -.flow-block-library-group.flow-palette-timeop { border-left-color:var(--info); } -.flow-block-library-group.flow-palette-sensor { border-left-color:var(--teal); } -.flow-block-library-group.flow-palette-logic { border-left-color:var(--purple); } -.flow-block-library-group.flow-palette-action { border-left-color:var(--orange); } -.flow-block-library-group.flow-palette-haaction { border-left-color:var(--accent); } -.flow-block-library-group h3 { margin:0 0 7px; color:var(--muted); font-size:.72rem; letter-spacing:.07em; text-transform:uppercase; } -.flow-block-library-group > div { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } -.flow-block-library-group button { min-height:42px; text-align:left; } - -/* Port stays visually small but gets a comfortable touch target. */ -.flow-editor .flow-port { width:16px; height:16px; margin-top:-8px; } -.flow-editor .flow-port-in { left:-9px; } -.flow-editor .flow-port-out { right:-9px; } -.flow-editor .flow-port::after { content:""; position:absolute; inset:-11px; border-radius:50%; } - -.flow-preview-toggle { display:none; } -.flow-preview-details { display:flex; justify-content:space-between; gap:24px; align-items:center; width:100%; min-width:0; } - -@media (min-width:900px) { - .mobile-nav-only { display:none !important; } - .desktop-nav-only { display:flex; } - .bottom-nav { - top:calc(var(--topbar-height,72px) + 14px); bottom:14px; left:max(14px,calc((100vw - 1500px)/2)); - display:flex; flex-direction:column; gap:2px; width:190px; max-height:calc(100dvh - var(--topbar-height,72px) - 28px); - padding:10px; overflow-y:auto; border-radius:16px; scrollbar-width:thin; - } - .bottom-nav .nav-section-label { padding:10px 9px 4px; } - .bottom-nav .nav-section-label:first-child { padding-top:3px; } - .bottom-nav button { display:flex; align-items:center; justify-content:flex-start; gap:10px; min-height:39px; padding:8px 10px; border-radius:10px; text-align:left; } - .bottom-nav button span { display:grid; place-items:center; flex:0 0 24px; width:24px; font-size:17px; } - .bottom-nav button b { min-width:0; overflow:hidden; font-size:11px; text-overflow:ellipsis; white-space:nowrap; } - .bottom-nav button.active { background:var(--accent-soft); } - main { width:min(1500px,100%); padding-left:222px; padding-right:24px; } -} - -@media (max-width:760px) { - main { padding:18px 12px calc(94px + env(safe-area-inset-bottom)); } - .desktop-nav-only { display:none !important; } - .mobile-nav-only { display:grid; } - .bottom-nav { grid-template-columns:repeat(5,minmax(0,1fr)); } - .bottom-nav button { min-height:48px; } - .bottom-nav button span { font-size:19px; } - - .section-heading { gap:12px; margin:4px 0 14px; } - .section-heading h1 { font-size:26px; } - .section-heading > button,.section-heading > .section-actions,.section-heading > .section-heading-actions { width:100%; } - .section-heading > button { min-height:44px; } - .flow-page-actions,.section-heading-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } - .flow-page-actions button,.section-heading-actions button { width:100%; min-height:44px; } - .automation-tabs { width:100%; margin-bottom:12px; scroll-snap-type:x proximity; } - .automation-tabs button { flex:1 0 auto; scroll-snap-align:start; } - - .device-card,.panel,.list-card { border-radius:14px; } - .device-card { padding:15px; } - .card-footer { gap:9px; } - .card-menu { width:100%; justify-content:flex-end; } - .card-menu > button { min-height:40px; } - .card-primary-action { flex:1 1 auto; } - .card-overflow summary { width:40px; height:40px; } - - dialog:not(.auth-dialog) { - inset:auto 0 0; width:100%; max-width:none; max-height:88dvh; margin:0; - border-right:0; border-bottom:0; border-left:0; border-radius:18px 18px 0 0; - overscroll-behavior:contain; - } - dialog:not(.auth-dialog) .dialog-head { position:sticky; top:0; z-index:5; padding-top:14px; background:var(--dialog-bg); } - dialog:not(.auth-dialog) .dialog-head::before { content:""; position:absolute; top:6px; left:50%; width:38px; height:4px; border-radius:999px; background:var(--line-strong); transform:translateX(-50%); } - .dialog-form { padding:14px 14px calc(14px + env(safe-area-inset-bottom)); } - .dialog-form .form-actions { position:sticky; bottom:0; z-index:4; margin:6px -14px -14px; padding:10px 14px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:color-mix(in srgb,var(--dialog-bg) 96%,transparent); backdrop-filter:blur(12px); } - .dialog-form .form-actions > button { flex:1 1 0; min-height:44px; } - - .history-tabs { margin-inline:-12px; padding-inline:12px; scroll-padding-inline:12px; scroll-snap-type:x proximity; } - .history-tabs button { min-height:40px; scroll-snap-align:start; } - .history-toolbar-panel { padding:12px; } - - .settings-mode-tabs { position:sticky; top:calc(var(--topbar-height,64px) + 6px); z-index:8; margin-inline:-4px; background:color-mix(in srgb,var(--bg) 94%,transparent); backdrop-filter:blur(12px); } - .settings-save-bar { bottom:calc(74px + env(safe-area-inset-bottom)); margin-inline:-4px; padding:10px; } - .settings-save-bar span { display:none; } - .settings-save-bar button { width:100%; min-height:44px; } - - /* Mobile Flow: compact header, canvas-first editor and bottom-sheet inspector. */ - .flow-editor-bar { - display:grid; grid-template-columns:minmax(0,1fr) auto; grid-template-rows:auto auto; - align-items:center; width:100%; gap:6px 8px; padding:7px 8px; overflow:hidden; - } - .flow-editor-title { grid-column:1; grid-row:1; width:100%; min-width:0; gap:8px; } - .flow-editor-title > div { width:100%; min-width:0; } - .flow-editor-title .eyebrow { display:none; } - .flow-name-wrap { display:flex; width:100%; min-width:0; gap:6px; } - .flow-name-view { min-width:0; flex:1 1 auto; } - .flow-name-text { min-width:0; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .flow-editor-title input { width:100%; min-width:0; flex:1 1 auto; font-size:1rem; } - .flow-editor-bar > .flow-save-button { - grid-column:2; grid-row:1; align-self:stretch; min-width:0; min-height:38px; - padding-inline:11px; white-space:nowrap; - } - .flow-editor-actions { - grid-column:1 / -1; grid-row:2; display:flex; align-items:center; justify-content:space-between; - width:100%; min-width:0; max-width:none; gap:8px; overflow:hidden; - } - .flow-editor-actions .flow-desktop-action,.flow-save-status,.flow-compile-status { display:none; } - .flow-editor-actions .flow-enabled { - display:flex; flex:0 1 auto; width:auto; min-width:0; min-height:34px; - justify-content:flex-start; gap:7px; padding:0 4px; font-size:.78rem; - } - .flow-editor-actions .flow-enabled span { display:inline; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .flow-editor-actions .flow-mobile-actions-button { - display:inline-flex; flex:0 0 auto; width:auto; min-width:96px; min-height:34px; - margin-left:auto; padding:6px 12px; justify-content:center; - } - - .flow-editor-body { position:relative; display:flex; flex:1 1 auto; flex-direction:column; height:0; min-width:0; min-height:0; overflow:hidden; } - .flow-workspace-wrap { - order:1; display:grid; grid-template-rows:auto minmax(0,1fr) auto; flex:1 1 auto; - width:100%; height:100%; min-width:0; min-height:0; overflow:hidden; - } - .flow-workspace-toolbar { display:flex; flex:0 0 auto; width:100%; min-width:0; min-height:48px; padding:6px 8px; gap:7px; justify-content:space-between; overflow:hidden; } - .flow-workspace-primary-actions { display:flex; flex:1 1 auto; min-width:0; overflow:hidden; } - .flow-add-block-button { display:inline-flex; min-width:0; max-width:128px; min-height:36px; padding:7px 10px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .flow-selection-action,#flowSelectionCount { display:none; } - .flow-zoom-controls { display:flex; flex:0 0 auto; margin-left:auto; gap:4px; } - .flow-zoom-controls .icon-button { width:34px; height:34px; min-height:34px; } - .flow-zoom-controls > span { display:none; } - .flow-fit-button { width:34px; min-width:34px; min-height:34px; padding:0; font-size:0; } - .flow-fit-button::before { content:"⊡"; font-size:17px; line-height:1; } - .flow-palette { display:none !important; } - .flow-workspace { height:100%; min-width:0; min-height:0 !important; max-height:none; overflow:auto; overscroll-behavior:contain; touch-action:pan-x pan-y; } - - .flow-natural-preview { display:block; flex:0 0 auto; min-height:0; max-height:none; padding:0; overflow:hidden; } - .flow-preview-toggle { - display:flex; align-items:center; justify-content:space-between; width:100%; min-height:38px; - padding:7px 10px; border:0; border-radius:0; background:var(--surface); color:var(--text); text-align:left; - } - .flow-preview-toggle strong { font-size:.78rem; } - .flow-preview-toggle-icon { color:var(--muted); font-size:1rem; transition:transform .15s ease; } - .flow-preview-details { display:none; flex-direction:column; align-items:stretch; gap:7px; max-height:32dvh; padding:8px 10px 10px; overflow:auto; } - .flow-natural-preview.is-expanded .flow-preview-details { display:flex; } - .flow-natural-preview.is-expanded .flow-preview-toggle { border-bottom:1px solid var(--line); } - .flow-natural-preview.is-expanded .flow-preview-toggle-icon { transform:rotate(180deg); } - .flow-preview-item { gap:1px; } - .flow-natural-preview strong { font-size:.78rem; } - .flow-natural-preview span { font-size:.76rem; } - - .flow-inspector { - display:none; position:fixed; inset:auto 0 0; width:100%; max-height:56dvh; - padding:22px 14px calc(14px + env(safe-area-inset-bottom)); overflow:auto; - border:0; border-top:1px solid var(--line-strong); border-radius:18px 18px 0 0; - z-index:95; box-shadow:0 -14px 34px rgba(0,0,0,.3); transition:max-height .18s ease; - } - .flow-inspector.has-selection { display:block; } - .flow-inspector::before { content:""; position:absolute; top:7px; left:50%; width:38px; height:4px; border-radius:999px; background:var(--line-strong); transform:translateX(-50%); } - .flow-inspector.is-expanded { max-height:90dvh; } - .flow-inspector-close,.flow-inspector-expand { display:inline-grid; place-items:center; } - .flow-inspector-head { position:sticky; top:-22px; z-index:4; margin:-8px -2px 14px; padding:10px 2px 8px; background:var(--surface); } - .flow-inspector-delete { padding-inline:10px; } - .flow-inspector .two { grid-template-columns:1fr; } - .flow-inspector label { font-size:.78rem; } - .flow-inspector input,.flow-inspector select,.flow-inspector textarea { min-height:44px; } - - .flow-block-dialog { max-height:82dvh !important; } - .flow-block-library-group > div { grid-template-columns:1fr; } - .flow-block-library-group button { min-height:46px; } - - .sensor-alias-row { grid-template-columns:minmax(0,1fr) 34px; } - .sensor-alias-entity { grid-column:1 / -1; } - .sensor-alias-row input { grid-column:1; } - .sensor-alias-clear { grid-column:2; } - - /* Do not let long settings/help copy dominate small screens. */ - .settings-block { gap:14px; } - .settings-block-head h3 { font-size:16px; } - .settings-block-head p,.field-note { line-height:1.45; } -} - -@media (max-width:420px) { - .section-heading h1 { font-size:24px; } - .flow-editor-actions .flow-mobile-actions-button { min-width:88px; padding-inline:10px; } -} - -/* Readability and form consistency fixes found during the wider UX pass. */ -textarea { font:inherit; } -textarea { - width:100%; min-height:96px; resize:vertical; border:1px solid var(--line); border-radius:12px; - outline:0; padding:10px 12px; color:var(--text); background:var(--input-bg); line-height:1.45; -} -textarea:focus { border-color:var(--accent); outline:2px solid color-mix(in srgb,var(--accent) 18%,transparent); outline-offset:1px; } -.flow-empty-hint button { pointer-events:auto; margin-top:7px; justify-self:center; } -#controlPlan .plan-card>p { font-size:11.5px; line-height:1.45; } -#controlPlan .plan-events li { font-size:10.5px; line-height:1.4; } -#controlPlan .plan-rule-summary strong { font-size:11px; } -#controlPlan .plan-rule-summary small { font-size:10px; } -#controlPlan .plan-card-head .eyebrow { font-size:9px; } - -@media (prefers-reduced-motion:reduce) { - *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; } -} -.flow-card[data-flow-card-id] { cursor:pointer; } -.flow-card[data-flow-card-id]:focus-visible { outline:2px solid color-mix(in srgb,var(--accent) 55%,transparent); outline-offset:2px; } -.flow-card[data-flow-card-id] button,.flow-card[data-flow-card-id] details { cursor:pointer; } -.flow-generated-card { border-left:3px solid var(--accent-border); } -.flow-generated-card .card-footer > small { color:var(--accent); } - -/* 0.12.x production layout guards and in-app chart preview. */ -html,body { max-width:100%; overflow-x:clip; } -main,.view { min-width:0; max-width:100%; } -.flow-info-panel,.flow-info-panel > div { min-width:0; } -.flow-info-panel > .badge { flex:0 1 auto; max-width:100%; white-space:normal; overflow-wrap:anywhere; text-align:center; } - -body.flow-editor-open { width:100%; height:100vh; height:100dvh; overflow:hidden; overscroll-behavior:none; } -.flow-editor { width:100vw; width:100dvw; height:100vh; height:100dvh; max-width:100%; overflow:hidden; } -.flow-editor-bar,.flow-editor-title,.flow-editor-actions,.flow-editor-body,.flow-workspace-wrap,.flow-workspace-toolbar { min-width:0; max-width:100%; } - -.chart-title-row > div:first-child { min-width:0; } -.chart-title-actions { display:flex; align-items:center; gap:7px; flex:0 0 auto; } -.chart-fullscreen-button { min-width:34px; height:32px; padding:0 9px; border:1px solid var(--line); background:var(--surface-muted); color:var(--text-soft); font-size:16px; line-height:1; } -.chart-fullscreen-button:hover { background:var(--surface-2); } -body.chart-fullscreen-open { overflow:hidden; } -body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-index:139; background:rgba(0,0,0,.68); backdrop-filter:blur(3px); } -.history-chart-card.chart-fullscreen-fallback { - position:fixed; left:50%; top:50%; z-index:140; - width:min(1200px,calc(100vw - 28px)); height:min(820px,calc(100dvh - 28px)); max-width:none; - margin:0; padding:14px; transform:translate(-50%,-50%); - display:grid; grid-template-rows:auto minmax(0,1fr) auto; gap:8px; overflow:hidden; - border:1px solid var(--line-strong); border-radius:16px; background:var(--surface); - box-shadow:0 28px 90px rgba(0,0,0,.58); -} -.history-chart-card.chart-fullscreen-fallback .chart-title-row { margin:0; } -.history-chart-card.chart-fullscreen-fallback .chart-title-row p, -.history-chart-card.chart-fullscreen-fallback .chart-hover-hint { display:none; } -.history-chart-card.chart-fullscreen-fallback .chart-wrap { height:100%; min-height:0; overflow:auto; border:1px solid var(--line); border-radius:10px; background:var(--surface-muted); } -.history-chart-card.chart-fullscreen-fallback .legend { max-height:88px; margin:0; overflow:auto; } - -@media (max-width:760px) { - body:not(.flow-editor-open) .bottom-nav { position:fixed; right:0; bottom:0; left:0; z-index:60; max-width:100vw; overflow-x:hidden; } - .flow-info-panel { flex-direction:column; align-items:flex-start; gap:12px; padding:16px; overflow:hidden; } - .flow-info-panel > div { width:100%; } - .flow-info-panel > .badge { align-self:flex-start; text-align:left; } - .flow-shared-settings-link { width:100%; max-width:100%; white-space:normal; text-align:left; } - .automation-tabs { width:100%; min-width:0; } - - .chart-title-row { gap:8px; } - .chart-title-actions { width:100%; justify-content:flex-end; } - .chart-fullscreen-button { min-height:36px; height:36px; } - .history-chart-card.chart-fullscreen-fallback { width:calc(100vw - 12px); height:calc(100dvh - 18px); padding:9px; border-radius:13px; } - .history-chart-card.chart-fullscreen-fallback .chart-title-row { flex-direction:row; align-items:center; gap:8px; } - .history-chart-card.chart-fullscreen-fallback .chart-title-row > div:first-child { flex:1 1 auto; min-width:0; } - .history-chart-card.chart-fullscreen-fallback .chart-title-row h3 { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .history-chart-card.chart-fullscreen-fallback .chart-title-actions { width:auto; flex:0 0 auto; } - .history-chart-card.chart-fullscreen-fallback .legend { max-height:70px; } -} - -@media (max-width:560px) { - .chart-title-actions { gap:5px; } - .chart-zoom-controls button { height:36px; } -} - -/* Manual unit control and technical device diagnostics. */ -.manual-zone-blocked { - border-color: color-mix(in srgb, var(--warning) 62%, var(--line)); -} - -.manual-zone-warning { - display: grid; - gap: 4px; - margin: 10px 0 2px; - padding: 9px 10px; - border: 1px solid color-mix(in srgb, var(--warning) 48%, var(--line)); - border-radius: 11px; - background: var(--warning-soft); -} - -.manual-zone-warning strong { - color: var(--warning); - font-size: 11px; -} - -.manual-zone-warning p { - margin: 0; - color: var(--text-soft); - font-size: 10px; - line-height: 1.4; -} - -.technical-device-card { - display: grid; - gap: 14px; - padding: 18px; - overflow: visible; -} - -.technical-device-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 14px; -} - -.technical-device-head .device-title h3 { - margin-top: 3px; -} - -.device-ping-button { - display: grid; - justify-items: end; - gap: 2px; - min-width: 88px; - min-height: 50px; - padding: 8px 10px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--surface-muted); -} - -.device-ping-button span { - color: var(--muted); - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: .06em; -} - -.device-ping-button strong { - color: var(--text); - font-size: 15px; - font-variant-numeric: tabular-nums; -} - -.device-ping-button:hover { - border-color: var(--accent-border); - background: var(--surface-2); -} - -.technical-device-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.technical-device-grid > div { - display: grid; - min-width: 0; - gap: 3px; - padding: 10px 11px; - border: 1px solid var(--line); - border-radius: 11px; - background: var(--surface-muted); -} - -.technical-device-grid span, -.technical-device-error span { - color: var(--muted); - font-size: 9px; - font-weight: 750; - text-transform: uppercase; - letter-spacing: .06em; -} - -.technical-device-grid strong, -.technical-device-grid small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.technical-device-grid strong { - color: var(--text-soft); - font-size: 12px; -} - -.technical-device-grid small { - color: var(--muted); - font-size: 10px; -} - -.technical-device-error { - display: grid; - gap: 4px; - padding: 9px 10px; - border: 1px solid color-mix(in srgb, var(--danger) 36%, var(--line)); - border-radius: 10px; - background: var(--danger-soft); -} - -.technical-device-error strong { - overflow: hidden; - color: var(--danger); - font-size: 11px; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -.technical-device-actions { - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: 6px; - padding-top: 2px; -} - -.technical-device-actions > button { - min-height: 36px; - padding: 7px 10px; - background: transparent; - color: var(--text-soft); - font-size: 11px; -} - -.ping-dialog .dialog-form { - gap: 12px; -} - -.ping-toolbar { - display: grid; - grid-template-columns: minmax(180px, 1fr) auto auto; - align-items: end; - gap: 10px; -} - -.ping-toolbar > label:first-child { - display: grid; - gap: 5px; -} - -.ping-all-toggle { - min-height: 42px; - margin: 0; - padding: 0 4px; - align-items: center; -} - -#pingToggleButton { - min-width: 96px; - min-height: 42px; -} - -.ping-live-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); - gap: 10px; -} - -.ping-live-card { - display: grid; - gap: 10px; - padding: 12px; - border: 1px solid var(--line); - border-radius: 13px; - background: var(--surface-muted); -} - -.ping-live-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; -} - -.ping-live-head > div { - min-width: 0; -} - -.ping-live-head strong, -.ping-live-head small { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ping-live-head strong { - font-size: 13px; -} - -.ping-live-head small { - margin-top: 2px; - color: var(--muted); - font-size: 10px; -} - -.ping-state { - flex: 0 0 auto; - padding: 4px 7px; - border-radius: 99px; - color: var(--muted); - background: var(--surface-2); - font-size: 9px; - font-weight: 750; -} - -.ping-state.online { - color: var(--accent); - background: var(--accent-soft); -} - -.ping-state.error { - color: var(--danger); - background: var(--danger-soft); -} - -.ping-sparkline { - width: 100%; - height: 92px; - overflow: visible; - border: 1px solid var(--line); - border-radius: 10px; - background: var(--surface); -} - -.ping-sparkline .ping-grid-lines line { - stroke: var(--line); - stroke-width: 1; - vector-effect: non-scaling-stroke; -} - -.ping-sparkline polyline { - fill: none; - stroke: var(--accent); - stroke-width: 2; - stroke-linejoin: round; - stroke-linecap: round; - vector-effect: non-scaling-stroke; -} - -.ping-empty { - display: grid; - place-items: center; - min-height: 92px; - border: 1px dashed var(--line); - border-radius: 10px; - color: var(--muted); - font-size: 11px; - text-align: center; -} - -.ping-stat-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 6px; -} - -.ping-stat-grid > div { - display: grid; - gap: 2px; - min-width: 0; -} - -.ping-stat-grid span { - color: var(--muted); - font-size: 8px; - font-weight: 700; - text-transform: uppercase; -} - -.ping-stat-grid strong { - overflow: hidden; - color: var(--text-soft); - font-size: 11px; - font-variant-numeric: tabular-nums; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ping-sample-count { - overflow: hidden; - color: var(--muted); - font-size: 9px; - text-overflow: ellipsis; - white-space: nowrap; -} - -@media (max-width: 620px) { - .technical-device-grid { grid-template-columns: 1fr; } - .technical-device-head { align-items: center; } - .device-ping-button { min-width: 76px; } - .ping-toolbar { grid-template-columns: 1fr auto; } - .ping-toolbar > label:first-child { grid-column: 1 / -1; } - .ping-all-toggle { justify-self: start; } - #pingToggleButton { justify-self: end; } - .ping-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } -} - -/* 0.12.x diagnostics, manual-control safety and technical connection checks. */ -.manual-device-problem { - display: grid; - gap: 4px; - margin: 0 0 12px; - padding: 9px 11px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--surface-2); -} -.manual-device-problem.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); } -.manual-device-problem.warning { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); } -.manual-device-problem strong { font-size: .82rem; } -.manual-device-problem small { color: var(--muted); overflow-wrap: anywhere; } -.config-check-result { - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: 10px; - font-size: .88rem; - font-weight: 650; - overflow-wrap: anywhere; -} -.config-check-result.success { border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); } -.config-check-result.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); } -.technical-config-actions { flex-wrap: wrap; } -@media (max-width: 640px) { - .technical-config-actions button { flex: 1 1 100%; } -} diff --git a/mock/theme-init.js b/mock/theme-init.js deleted file mode 100644 index b5bb25f..0000000 --- a/mock/theme-init.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; -(() => { - const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_theme=')); - const requested = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : 'system'; - const resolved = requested === 'light' || requested === 'dark' - ? requested - : (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); - document.documentElement.dataset.theme = resolved; -})(); diff --git a/scripts/verify_flow_logic.py b/scripts/verify_flow_logic.py deleted file mode 100755 index 78d9927..0000000 --- a/scripts/verify_flow_logic.py +++ /dev/null @@ -1,473 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations -import json -import math -import re -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - -CONDITIONS = { - 'weekday','time_range','date_range','cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates', - 'outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute', - 'ha_available','house_mode','device_state','zone_state','group_state','night_mode','constant','shared_input' -} -LOGIC = {'logic_and','logic_or','logic_not'} -ACTIONS = {'zone_thermostat','device_action','group_action','ha_service_action'} -ALL = CONDITIONS | LOGIC | ACTIONS - -passed = 0 - -def check(value, message): - global passed - if not value: - raise AssertionError(message) - passed += 1 - - -def cron_field_valid(field: str, lo: int, hi: int, allow_seven=False): - upper = 7 if allow_seven else hi - if not field.strip(): return False - for part in field.split(','): - part = part.strip() - if part == '*': continue - if part.startswith('*/'): - try: step = int(part[2:]) - except ValueError: return False - if not (0 < step <= hi - lo + 1): return False - continue - if '-' in part: - try: a,b = map(int, part.split('-',1)) - except ValueError: return False - if not (lo <= a <= b <= upper): return False - continue - try: v = int(part) - except ValueError: return False - if not (lo <= v <= upper): return False - return True - - -def cron_valid(expr: str): - f=expr.split() - return len(f)==5 and cron_field_valid(f[0],0,59) and cron_field_valid(f[1],0,23) and cron_field_valid(f[2],1,31) and cron_field_valid(f[3],1,12) and cron_field_valid(f[4],0,6,True) - - -def cron_part_match(part, value, lo, hi): - if part == '*': return True - if part.startswith('*/'): - step=int(part[2:]); return lo <= value <= hi and (value-lo)%step==0 - if '-' in part: - a,b=map(int,part.split('-',1)); return a <= value <= b and a >= lo and b <= hi - v=int(part); return v==value and lo <= v <= hi - - -def cron_field_match(field,value,lo,hi): - return any(cron_part_match(p.strip(),value,lo,hi) for p in field.split(',')) - - -def cron_matches(expr: str, dt: datetime): - if not cron_valid(expr): return False - f=expr.split(); # Python Monday=0; backend Sunday=0 - weekday=(dt.weekday()+1)%7 - return (cron_field_match(f[0],dt.minute,0,59) and cron_field_match(f[1],dt.hour,0,23) - and cron_field_match(f[2],dt.day,1,31) and cron_field_match(f[3],dt.month,1,12) - and (cron_field_match(f[4],weekday,0,7) or (weekday==0 and cron_field_match(f[4],7,0,7)))) - -@dataclass -class Runtime: - since: datetime|None=None - samples: list[tuple[datetime,float]]=field(default_factory=list) - last_value: object|None=None - -def timed_gate(rt: Runtime, input_value: bool, seconds: int, now: datetime): - if not input_value: - rt.since=None; return False - if rt.since is None: rt.since=now - return (now-rt.since).total_seconds() >= seconds - -def state_duration(rt: Runtime, input_value: bool, minimum: int, maximum: int|None, now: datetime): - if not input_value: - rt.since=None; return False,0 - if rt.since is None: rt.since=now - elapsed=max(0,int((now-rt.since).total_seconds())) - return elapsed >= minimum and (maximum is None or elapsed <= maximum), elapsed - -def changed(rt: Runtime, current): - result=rt.last_value is not None and rt.last_value != current - rt.last_value=current - return result - -def rate_limit_status(rt: Runtime, maximum: int, period: int, now: datetime): - cutoff=now-timedelta(seconds=period) - rt.samples=[x for x in rt.samples if x[0] >= cutoff] - return len(rt.samples) < maximum, len(rt.samples) - -def rate_limit_record(rt: Runtime, period: int, now: datetime): - cutoff=now-timedelta(seconds=period) - rt.samples=[x for x in rt.samples if x[0] >= cutoff] - rt.samples.append((now,1.0)) - -def rolling(rt: Runtime, sample: float, window: int, statistic: str, now: datetime): - cutoff=now-timedelta(seconds=window) - rt.samples=[x for x in rt.samples if x[0] >= cutoff] - rt.samples.append((now,sample)) - vals=[v for _,v in rt.samples] - if statistic == 'median': - vals=sorted(vals); mid=len(vals)//2 - return (vals[mid-1]+vals[mid])/2 if len(vals)%2==0 else vals[mid] - return sum(vals)/len(vals) - -def oscillation_metrics(values): - if len(values)<3: return None - span=max(values)-min(values) - prev=0; changes=0 - for a,b in zip(values,values[1:]): - d=b-a; sign=1 if d>1e-6 else -1 if d<-1e-6 else 0 - if not sign: continue - if prev and sign != prev: changes += 1 - prev=sign - return span,changes - -def oscillates(rt: Runtime, sample: float, window: int, min_span: float, min_changes: int, now: datetime): - cutoff=now-timedelta(seconds=window) - rt.samples=[x for x in rt.samples if x[0] >= cutoff] - rt.samples.append((now,sample)) - metrics=oscillation_metrics([v for _,v in rt.samples]) - return bool(metrics and metrics[0] >= min_span and metrics[1] >= min_changes) - - -NUMERIC_OPS = {'lt','lte','gt','gte','eq','neq'} -TEXT_OPS = {'eq','neq'} -DEVICE_STATE_FIELDS = {'enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'} -ZONE_STATE_FIELDS = {'enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'} -THERMOSTAT_PRESETS = {'auto','comfort','sleep','away','custom'} -THERMOSTAT_MODES = {'auto','heat','cool'} -GROUP_PRESETS = {'auto','comfort','sleep','away','custom'} -GROUP_MODES = {'auto','house','cool','heat'} -ROLLING_SOURCES = {'outdoor_temperature','device_temperature','zone_temperature','ha_numeric'} - -def nonempty(value): - return isinstance(value,str) and bool(value.strip()) - -def finite_number(value): - return isinstance(value,(int,float)) and not isinstance(value,bool) and math.isfinite(float(value)) - -def optional_text(value): - return value.strip() if isinstance(value,str) and value.strip() else None - -def validate_numeric_comparison(config, name): - check(config.get('operator','lt') in NUMERIC_OPS, f'{name}: unsupported numeric operator') - check(finite_number(config.get('value')), f'{name}: numeric comparison needs finite value') - -def validate_text_comparison_config(config, name): - check(config.get('operator','eq') in TEXT_OPS, f'{name}: unsupported text operator') - check('value' in config, f'{name}: state comparison needs value') - -def validate_source_config(config, name): - source=config.get('source') - check(source in ROLLING_SOURCES, f'{name}: unsupported statistic source') - if source == 'device_temperature': check(nonempty(config.get('device_id')), f'{name}: device source needs device_id') - if source == 'zone_temperature': check(nonempty(config.get('zone_id')), f'{name}: zone source needs zone_id') - if source == 'ha_numeric': check(nonempty(config.get('entity_id')), f'{name}: HA source needs entity_id') - -def validate_node_config(node, name): - kind=node['kind']; c=node.get('config',{}) - check(isinstance(c,dict), f'{name}/{node.get("id")}: config is not an object') - tag=f'{name}/{node.get("id")}:{kind}' - if kind == 'weekday': - days=c.get('days'); check(isinstance(days,list) and bool(days), f'{tag}: weekdays missing') - check(all(isinstance(d,int) and not isinstance(d,bool) and 1 <= d <= 7 for d in days), f'{tag}: invalid weekday') - elif kind == 'time_range': - for key in ('start','end'): - try: datetime.strptime(c.get(key,''),'%H:%M') - except (TypeError,ValueError): check(False, f'{tag}: invalid {key} time') - else: check(True, f'{tag}: valid {key} time') - elif kind == 'date_range': - try: - start=datetime.strptime(c.get('start',''),'%Y-%m-%d').date(); end=datetime.strptime(c.get('end',''),'%Y-%m-%d').date() - except (TypeError,ValueError): - check(False, f'{tag}: invalid date range') - else: - check(start <= end, f'{tag}: reversed date range') - elif kind == 'cron_trigger': - check(nonempty(c.get('expression')) and cron_valid(c['expression']), f'{tag}: invalid cron') - elif kind in {'stable_for','delay'}: - seconds=c.get('seconds'); check(isinstance(seconds,int) and not isinstance(seconds,bool) and 1 <= seconds <= 604800, f'{tag}: invalid duration') - elif kind == 'state_duration': - minimum=c.get('min_seconds',0); maximum=c.get('max_seconds') - check(isinstance(minimum,int) and not isinstance(minimum,bool) and 0 <= minimum <= 604800, f'{tag}: invalid min duration') - check(maximum is None or (isinstance(maximum,int) and not isinstance(maximum,bool) and minimum <= maximum <= 604800), f'{tag}: invalid max duration') - check(minimum > 0 or maximum is not None, f'{tag}: empty duration range') - elif kind == 'on_change': - check(c.get('mode','result') in {'result','value'}, f'{tag}: invalid change mode') - elif kind == 'rate_limit': - count=c.get('max_count'); period=c.get('period_seconds') - check(isinstance(count,int) and not isinstance(count,bool) and 1 <= count <= 1000, f'{tag}: invalid max count') - check(isinstance(period,int) and not isinstance(period,bool) and 1 <= period <= 2678400, f'{tag}: invalid rate period') - elif kind == 'rolling_stat': - validate_source_config(c,tag) - window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window') - check(c.get('statistic') in {'mean','median'}, f'{tag}: invalid statistic') - validate_numeric_comparison(c,tag) - elif kind == 'oscillates': - validate_source_config(c,tag) - window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window') - check(finite_number(c.get('min_span')) and float(c['min_span']) > 0, f'{tag}: min_span must be positive') - changes=c.get('min_direction_changes'); check(isinstance(changes,int) and not isinstance(changes,bool) and 1 <= changes <= 1000, f'{tag}: invalid direction-change count') - elif kind == 'outdoor_temperature': - validate_numeric_comparison(c,tag) - elif kind == 'device_temperature': - check(nonempty(c.get('device_id')), f'{tag}: device_id missing'); validate_numeric_comparison(c,tag) - elif kind == 'zone_temperature': - check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing'); validate_numeric_comparison(c,tag) - elif kind == 'ha_state': - check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing'); validate_text_comparison_config(c,tag) - elif kind == 'ha_numeric': - check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing'); validate_numeric_comparison(c,tag) - elif kind == 'ha_attribute': - check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing') - check(nonempty(c.get('attribute')), f'{tag}: attribute missing') - check(c.get('operator','eq') in NUMERIC_OPS, f'{tag}: invalid attribute operator') - check('value' in c, f'{tag}: attribute value missing') - elif kind == 'ha_available': - check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing') - elif kind == 'house_mode': - check(c.get('value') in {'cool','heat','off'}, f'{tag}: invalid house mode'); validate_text_comparison_config(c,tag) - elif kind == 'device_state': - check(nonempty(c.get('device_id')), f'{tag}: device_id missing') - check(c.get('field') in DEVICE_STATE_FIELDS, f'{tag}: invalid device field'); validate_text_comparison_config(c,tag) - elif kind == 'zone_state': - check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing') - check(c.get('field') in ZONE_STATE_FIELDS, f'{tag}: invalid zone field'); validate_text_comparison_config(c,tag) - elif kind == 'group_state': - check(nonempty(c.get('group_id')), f'{tag}: group_id missing') - check(c.get('field') == 'power_enabled', f'{tag}: invalid group field'); validate_text_comparison_config(c,tag) - elif kind == 'constant': - check(isinstance(c.get('value'),bool), f'{tag}: constant needs boolean') - elif kind == 'shared_input': - check(nonempty(c.get('input_id')), f'{tag}: input_id missing') - elif kind == 'zone_thermostat': - check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing') - preset=optional_text(c.get('preset')) or 'comfort'; check(preset in THERMOSTAT_PRESETS, f'{tag}: invalid preset') - mode=optional_text(c.get('mode')); check(mode is None or mode in THERMOSTAT_MODES, f'{tag}: invalid mode') - if preset == 'custom': check(finite_number(c.get('setpoint')) and 8 <= float(c['setpoint']) <= 30, f'{tag}: invalid custom target') - if 'power' in c and c['power'] is not None: check(isinstance(c['power'],bool), f'{tag}: power is not boolean') - if 'cooldown_seconds' in c: check(isinstance(c['cooldown_seconds'],int) and c['cooldown_seconds'] >= 0, f'{tag}: invalid cooldown') - elif kind == 'device_action': - check(nonempty(c.get('device_id')), f'{tag}: device_id missing') - fields=('power','mode','target_temperature','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep') - check(any(c.get(field) is not None for field in fields), f'{tag}: empty device action') - elif kind == 'group_action': - check(nonempty(c.get('group_id')), f'{tag}: group_id missing') - mode=optional_text(c.get('mode')); preset=optional_text(c.get('preset')) - check(mode is None or mode in GROUP_MODES, f'{tag}: invalid group mode') - check(preset is None or preset in GROUP_PRESETS, f'{tag}: invalid group preset') - check(c.get('power') is not None or mode is not None or preset is not None, f'{tag}: empty group action') - if preset == 'custom': check(finite_number(c.get('setpoint')) and 8 <= float(c['setpoint']) <= 30, f'{tag}: invalid custom target') - elif kind == 'ha_service_action': - check(nonempty(c.get('domain')), f'{tag}: domain missing'); check(nonempty(c.get('service')), f'{tag}: service missing') - # Empty entity_id is intentionally treated as omitted by backend flow_string(). - check(isinstance(c.get('data',{}),dict), f'{tag}: HA service data must be object') - elif kind in LOGIC or kind == 'night_mode': - pass - -def flow_action_truth_table(flow, action_id): - by={n['id']:n for n in flow.get('nodes',[])} - incoming={} - for edge in flow.get('edges',[]): incoming.setdefault(edge['to'],[]).append(edge['from']) - ancestors=set(); stack=list(incoming.get(action_id,[])) - while stack: - node_id=stack.pop() - if node_id in ancestors: continue - ancestors.add(node_id); stack.extend(incoming.get(node_id,[])) - primitive=[node_id for node_id in ancestors if by[node_id]['kind'] not in LOGIC and by[node_id]['kind'] not in {'stable_for','delay','state_duration','on_change','rate_limit'}] - check(len(primitive) <= 16, f'action {action_id}: truth table unexpectedly large') - - def eval_node(node_id, assigned, memo): - if node_id in memo: return memo[node_id] - node=by[node_id]; kind=node['kind']; ins=incoming.get(node_id,[]) - if kind == 'logic_and': value=bool(ins) and all(eval_node(x,assigned,memo) for x in ins) - elif kind == 'logic_or': value=bool(ins) and any(eval_node(x,assigned,memo) for x in ins) - elif kind == 'logic_not': value=len(ins)==1 and not eval_node(ins[0],assigned,memo) - elif kind in {'stable_for','delay','state_duration','on_change','rate_limit'}: value=len(ins)==1 and eval_node(ins[0],assigned,memo) - else: - predecessors=all(eval_node(x,assigned,memo) for x in ins) - value=predecessors and assigned.get(node_id,False) - memo[node_id]=value; return value - - outcomes=[] - for mask in range(1 << len(primitive)): - assigned={node_id: bool(mask & (1 << index)) for index,node_id in enumerate(primitive)} - memo={} - outcomes.append(bool(incoming.get(action_id)) and all(eval_node(x,assigned,memo) for x in incoming.get(action_id,[]))) - return outcomes - -def validate_graph(flow, name): - nodes=flow.get('nodes',[]); edges=flow.get('edges',[]) - check(bool(nodes), f'{name}: no nodes') - ids=[n.get('id') for n in nodes] - check(all(ids) and len(ids)==len(set(ids)), f'{name}: duplicate/empty node ids') - by={n['id']:n for n in nodes} - check(all(n.get('kind') in ALL for n in nodes), f'{name}: unsupported node kind') - check(any(n['kind'] in ACTIONS for n in nodes), f'{name}: no action') - edge_ids=[e.get('id') for e in edges] - check(all(edge_ids) and len(edge_ids)==len(set(edge_ids)), f'{name}: duplicate/empty edge ids') - pairs=[] - incoming={} - outgoing={} - for e in edges: - a,b=e.get('from'),e.get('to') - check(a in by and b in by and a != b, f'{name}: invalid edge endpoint') - check(by[a]['kind'] not in ACTIONS, f'{name}: action has outgoing edge') - pairs.append((a,b)); incoming.setdefault(b,[]).append(a); outgoing.setdefault(a,[]).append(b) - check(len(pairs)==len(set(pairs)), f'{name}: duplicate connections') - # DAG - temp=set(); done=set() - def visit(x): - if x in done: return - check(x not in temp, f'{name}: cycle') - temp.add(x) - for y in outgoing.get(x,[]): visit(y) - temp.remove(x); done.add(x) - for x in ids: visit(x) - for n in nodes: - validate_node_config(n, name) - ins=incoming.get(n['id'],[]) - if n['kind']=='logic_not': check(len(ins)==1, f'{name}: NOT arity') - if n['kind'] in {'logic_and','logic_or'}: check(len(ins)>=1, f'{name}: logic arity') - if n['kind'] in {'stable_for','delay','state_duration','on_change','rate_limit'}: check(len(ins)==1, f'{name}: stateful gate arity') - if n['kind'] in ACTIONS: check(len(ins)>=1, f'{name}: action without condition') - if n['kind'] == 'rate_limit': - targets=[by[target] for target in outgoing.get(n['id'],[]) if target in by] - check(bool(targets) and all(target['kind'] in ACTIONS for target in targets), f'{name}: rate limit must be directly before action') - if n['kind'] == 'on_change' and n.get('config',{}).get('mode','result') == 'value': - sources=[by[source] for source in incoming.get(n['id'],[]) if source in by] - blocked=LOGIC | {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'} - check(len(sources)==1 and sources[0]['kind'] not in blocked, f'{name}: value-change mode needs direct source input') - for action in (n for n in nodes if n['kind'] in ACTIONS): - outcomes=flow_action_truth_table(flow, action['id']) - check(any(outcomes), f'{name}/{action["id"]}: action condition graph can never become true') - return len(nodes),len(edges) - - -def main(): - # Deterministic backend semantics. - check(cron_valid('*/5 * * * *'), 'cron */5 invalid') - check(cron_valid('0,15,30,45 6-18 * * 1-5'), 'cron list/range invalid') - for bad in ('* * * *','*/0 * * * *','61 * * * *','* 24 * * *','* * 0 * *','* * * 13 *','* * * * 8','*/100 * * * *'): - check(not cron_valid(bad), f'cron accepted invalid: {bad}') - monday=datetime(2026,9,7,10,15) - check(cron_matches('15 10 * * 1', monday), 'cron exact weekday mismatch') - check(cron_matches('15 10 * * 1-7', monday), 'cron weekday range containing 7 mismatch') - check(not cron_matches('16 10 * * 1', monday), 'cron false positive') - sunday=datetime(2026,9,6,8,0) - check(cron_matches('0 8 * * 0', sunday) and cron_matches('0 8 * * 7', sunday) and cron_matches('0 8 * * 1-7', sunday), 'cron Sunday 0/7/range mismatch') - - base=datetime(2026,9,2,12,0,0) - rt=Runtime(); check(not timed_gate(rt,True,30,base),'stable fired immediately'); check(not timed_gate(rt,True,30,base+timedelta(seconds=29)),'stable fired early'); check(timed_gate(rt,True,30,base+timedelta(seconds=30)),'stable did not fire'); check(not timed_gate(rt,False,30,base+timedelta(seconds=31)),'stable did not reset'); check(not timed_gate(rt,True,30,base+timedelta(seconds=32)),'stable restart should wait') - rt=Runtime(); check(not timed_gate(rt,True,3,base),'delay immediate'); check(timed_gate(rt,True,3,base+timedelta(seconds=3)),'delay did not pass after wait') - - rt=Runtime(); check(state_duration(rt,True,10,30,base)==(False,0),'state duration immediate'); check(state_duration(rt,True,10,30,base+timedelta(seconds=10))==(True,10),'state duration minimum'); check(state_duration(rt,True,10,30,base+timedelta(seconds=30))==(True,30),'state duration maximum boundary'); check(state_duration(rt,True,10,30,base+timedelta(seconds=31))==(False,31),'state duration exceeded maximum'); check(state_duration(rt,False,10,30,base+timedelta(seconds=32))==(False,0) and rt.since is None,'state duration reset') - rt=Runtime(); check(not changed(rt,'off'),'change fired on first observation'); check(not changed(rt,'off'),'change fired without change'); check(changed(rt,'on'),'change not detected'); check(not changed(rt,'on'),'change repeated without new edge') - rt=Runtime(); rate_limit_record(rt,60,base); rate_limit_record(rt,60,base+timedelta(seconds=10)); check(rate_limit_status(rt,2,60,base+timedelta(seconds=20))==(False,2),'rate limit did not block'); check(rate_limit_status(rt,2,60,base+timedelta(seconds=61))==(True,1),'rate limit did not prune rolling window') - - synthetic={ - 'nodes':[ - {'id':'source','kind':'constant','config':{'value':True}}, - {'id':'duration','kind':'state_duration','config':{'min_seconds':10,'max_seconds':60}}, - {'id':'change','kind':'on_change','config':{'mode':'result'}}, - {'id':'limit','kind':'rate_limit','config':{'max_count':2,'period_seconds':3600}}, - {'id':'action','kind':'ha_service_action','config':{'domain':'switch','service':'turn_on','entity_id':'switch.test','data':{}}}, - ], - 'edges':[ - {'id':'e1','from':'source','to':'duration'}, {'id':'e2','from':'duration','to':'change'}, - {'id':'e3','from':'change','to':'limit'}, {'id':'e4','from':'limit','to':'action'}, - ] - } - check(validate_graph(synthetic,'synthetic-new-stateful-flow')==(5,4),'new stateful flow graph validation failed') - - rt=Runtime(); check(math.isclose(rolling(rt,10,60,'mean',base),10),'mean one'); check(math.isclose(rolling(rt,20,60,'mean',base+timedelta(seconds=10)),15),'mean two'); check(math.isclose(rolling(rt,30,60,'median',base+timedelta(seconds=20)),20),'median odd'); check(math.isclose(rolling(rt,40,60,'median',base+timedelta(seconds=30)),25),'median even'); check(math.isclose(rolling(rt,100,60,'mean',base+timedelta(seconds=100)),100),'window prune') - rt=Runtime(); - for i,v in enumerate((20,22,19,23,20)): - result=oscillates(rt,v,300,3,2,base+timedelta(seconds=i*10)) - check(result,'oscillation not detected') - rt=Runtime(); - for i,v in enumerate((20,21,22,23,24)): - result=oscillates(rt,v,300,3,2,base+timedelta(seconds=i*10)) - check(not result,'monotonic trend detected as oscillation') - - # Static backend/frontend wiring checks. - engine=(ROOT/'src/engine/automations.rs').read_text() - api=(ROOT/'src/api/flows.rs').read_text() - js=(ROOT/'web/js/flows.js').read_text() - html=(ROOT/'web/index.html').read_text() - for kind in ('cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'): - check(f'"{kind}"' in api and f'"{kind}"' in engine, f'{kind}: backend wiring missing') - check(kind in js and f'data-flow-add="{kind}"' in html, f'{kind}: UI wiring missing') - meta_block=js.split('const FLOW_NODE_META = Object.freeze({',1)[1].split('});',1)[0] - meta_kinds=set(re.findall(r'^\s*([a-z_]+):\s*\{', meta_block, re.M)) - palette_kinds=set(re.findall(r'data-flow-add="([a-z_]+)"', html)) - check(meta_kinds == ALL, f'UI metadata/backend kind mismatch: missing={ALL-meta_kinds}, extra={meta_kinds-ALL}') - check(palette_kinds == ALL, f'palette/backend kind mismatch: missing={ALL-palette_kinds}, extra={palette_kinds-ALL}') - check('"ha_service_action" => Ok(None)' in api, 'HA action missing dry-run support') - check('home_assistant::call_service' in engine and 'action_ha_domain' in engine, 'HA action runtime missing') - check('format!("ha:{entity_id}")' in engine, 'HA target conflict claim missing') - check('Some(&mut runtime)' in api, 'dry-run does not use stateful runtime') - check('conditions.iter().any(|condition| condition.kind == "cron_trigger")' in engine and 'last.minute() == now.minute()' in engine, 'CRON same-minute duplicate guard missing') - check('previous.config == current.config' in api and 'runtime.retain' in api, 'runtime reset-on-edit protection missing') - check('flow_record_rate_limited_execution' in engine and 'Ok(true)' in engine, 'rate limit is not recorded after successful execution') - check('if !item.enabled { continue; }' in engine and 'if !ready || !should_fire { continue; }' in engine, 'Flow runtime may be skipped during action cooldown') - check('rate-limit block must be placed directly before an action' in api, 'rate-limit placement guard missing') - check('on-change value mode needs one direct source/condition input' in api, 'on-change value-source guard missing') - models=(ROOT/'src/models/flow.rs').read_text() - core=(ROOT/'web/js/core.js').read_text() - configuration_api=(ROOT/'src/api/configuration.rs').read_text() - check('pub draft: bool' in models and '#[serde(default)]' in models, 'Flow draft persistence field missing') - check('fn validate_flow_draft_graph' in api, 'draft structural validator missing') - check(api.count('if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }') >= 3, 'draft create/update/import validation split missing') - check('enabled: if draft { false } else { input.enabled }' in api, 'draft does not force Flow disabled') - check(api.count('(prepare_draft_flow(flow), vec![], vec![])') >= 3, 'draft save/import may compile executable outputs') - check('flow.compiled_schedule_ids.clear()' in api and 'flow.compiled_automation_ids.clear()' in api, 'draft output clearing missing') - check('"draft": flow.draft' in api, 'draft state missing from Flow export') - check('import contains an executable Flow draft' in configuration_api, 'configuration import does not enforce draft safety invariant') - check('error.status = response.status' in core, 'frontend API errors do not expose HTTP validation status') - check("error.status !== 400" in js and "draft:true" in js and "enabled:false" in js, 'save-as-draft retry flow missing') - check("if (flow.draft) return toast(tr('flow.draftCannotEnable')" in js, 'draft quick-enable guard missing') - check("draft: app.flowDraft.draft === true" in js, 'draft portability state missing from source payload') - runtime_leaf=engine.split('async fn flow_leaf_observation',1)[1].split('pub async fn evaluate_flow_conditions_trace',1)[0] - for kind in CONDITIONS - {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'}: - check(f'\"{kind}\" =>' in runtime_leaf or (kind == 'shared_input' and 'condition.kind == \"shared_input\"' in runtime_leaf), f'{kind}: runtime leaf implementation missing') - evaluator=engine.split('pub async fn evaluate_flow_conditions_trace',1)[1].split('async fn flow_conditions_match',1)[0] - for kind in ('stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates','logic_and','logic_or','logic_not'): - check(f'\"{kind}\"' in evaluator, f'{kind}: evaluator implementation missing') - for category in ('trigger','time','timeop','sensor','logic','action','haaction'): - check(f'flow-palette-{category}' in html, f'palette category {category} missing') - css=(ROOT/'web/styles.css').read_text() - for marker in ('.flow-palette-group button', 'min-height:30px', 'padding:6px 8px', '.flow-palette-timeop', '.flow-palette-haaction', '.flow-editor-title input:hover', '.flow-editor-title input:focus', '.flow-draft-badge'): - check(marker in css, f'compact/color Flow CSS missing: {marker}') - - # Translation pack correctness: all new keys must be inside translations, not root. - for lang in ('pl','en'): - pack=json.loads((ROOT/f'lang/{lang}.json').read_text()) - check(not any(k.startswith('flow.') for k in pack), f'{lang}: flow translations leaked to root') - for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.stateDuration','flow.node.onChange','flow.node.rateLimit','flow.node.delay','flow.node.rollingStat','flow.node.oscillates','flow.node.haServiceAction','flow.draft','flow.draftStatus','flow.draftNoExecution','flow.draftDisabledHint','flow.draftCannotEnable','flow.saveAsDraftConfirm','flow.savedAsDraft'): - check(bool(pack.get('translations',{}).get(key)), f'{lang}: missing {key}') - title_keys=set(re.findall(r"titleKey: '([^']+)'", meta_block)) - missing_titles=sorted(key for key in title_keys if not pack.get('translations',{}).get(key)) - check(not missing_titles, f'{lang}: missing Flow node title translations: {missing_titles}') - referenced_flow_keys=set(re.findall(r'''(?:data-i18n(?:-[a-z]+)?=|tr\()\s*[\"'](flow\.[A-Za-z0-9_.]+)[\"']''', js + '\n' + html)) - missing_referenced=sorted(key for key in referenced_flow_keys if key not in pack.get('translations',{})) - check(not missing_referenced, f'{lang}: missing referenced Flow translations: {missing_referenced}') - - # Regression pass over every bundled Flow preset. - preset_files=sorted((ROOT/'presets').glob('*.json')) - check(len(preset_files) >= 30, 'unexpectedly small preset library') - nodes=edges=0 - for f in preset_files: - data=json.loads(f.read_text()) - n,e=validate_graph(data['flow'],f.name); nodes+=n; edges+=e - print(f'PASS: {passed} logical assertions; {len(preset_files)} presets; {nodes} nodes; {edges} edges') - -if __name__=='__main__': main() diff --git a/src/api.rs b/src/api.rs index 5280d8c..9a47b00 100644 --- a/src/api.rs +++ b/src/api.rs @@ -253,9 +253,6 @@ pub fn router(state: AppState) -> Router { .route(APP_JS_ASSET_PATH, get(app_js)) .route(THEME_INIT_ASSET_PATH, get(theme_init_js)) .route(STYLES_CSS_ASSET_PATH, get(styles_css)) - .route("/app.js", get(app_js_legacy)) - .route("/theme-init.js", get(theme_init_js_legacy)) - .route("/styles.css", get(styles_css_legacy)) .route("/manifest.webmanifest", get(manifest)) .route("/sw.js", get(service_worker)) .route("/favicon.svg", get(favicon)) diff --git a/src/api/assets.rs b/src/api/assets.rs index 82db2e8..1d64da6 100644 --- a/src/api/assets.rs +++ b/src/api/assets.rs @@ -24,7 +24,7 @@ async fn not_found(State(state): State, headers: HeaderMap) -> Respons ); response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_static("private, no-store, no-cache, must-revalidate"), + HeaderValue::from_static("private, no-store, no-cache"), ); response } @@ -49,7 +49,7 @@ async fn index(State(state): State, headers: HeaderMap) -> Response { ); response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_static("private, no-store, no-cache, must-revalidate"), + HeaderValue::from_static("private, no-store, no-cache"), ); response } @@ -98,13 +98,6 @@ async fn theme_init_js() -> Response { "public, max-age=31536000, immutable", ) } -async fn theme_init_js_legacy() -> Response { - static_response( - THEME_INIT_JS, - "application/javascript; charset=utf-8", - "no-cache", - ) -} async fn styles_css() -> Response { static_response( STYLES_CSS, @@ -112,9 +105,6 @@ async fn styles_css() -> Response { "public, max-age=31536000, immutable", ) } -async fn styles_css_legacy() -> Response { - static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") -} async fn manifest() -> Response { static_response( MANIFEST, @@ -160,7 +150,7 @@ async fn preset_index() -> Response { static_response( PRESET_MANIFEST_JSON, "application/json; charset=utf-8", - "no-cache", + "no-cache, no-store", ) } async fn preset_file(Path(file): Path) -> Response { diff --git a/src/api/middleware.rs b/src/api/middleware.rs index ce29a93..6636de3 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -1,6 +1,10 @@ async fn security_headers(request: Request, next: Next) -> Response { - let is_api = request.uri().path().contains("/api/"); + let path = request.uri().path(); + let is_api = path == "/api" || path.starts_with("/api/"); + let is_api_docs = path == "/api-docs" || path.starts_with("/api-docs/"); + let mut response = next.run(request).await; + let is_html = response .headers() .get(header::CONTENT_TYPE) @@ -13,6 +17,7 @@ async fn security_headers(request: Request, next: Next) -> Response { }); let headers = response.headers_mut(); + headers.insert( header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"), @@ -27,7 +32,12 @@ async fn security_headers(request: Request, next: Next) -> Response { header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN"), ); - headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'")); + headers.insert( + header::HeaderName::from_static("content-security-policy"), + HeaderValue::from_static( + "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'" + ), + ); headers.insert( header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), @@ -35,7 +45,16 @@ async fn security_headers(request: Request, next: Next) -> Response { } if is_api { - headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-store"), + ); + } else if is_api_docs { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, must-revalidate"), + ); } + response } diff --git a/web/styles.css b/web/styles.css index 6572ff6..3ed05e2 100644 --- a/web/styles.css +++ b/web/styles.css @@ -2593,7 +2593,7 @@ legend { background: color-mix(in srgb, var(--surface-muted) 52%, var(--surface)); } -.simulation-panel-head > div:first-child { +.simulation-panel-head>div:first-child { min-width: 0; } @@ -2685,7 +2685,7 @@ legend { background: var(--surface-muted); } -.simulation-rule-card > p { +.simulation-rule-card>p { margin: 0; color: var(--muted); font-size: 12px; @@ -8947,7 +8947,6 @@ body.flow-editor-open { vector-effect: non-scaling-stroke; } -/* Mobile UX refinements: dashboard headers, control plan, landscape navigation and Flow toolbar. */ @media (max-width: 700px) { .dashboard-thermostat-section .dashboard-section-head.compact-head { flex-direction: row; @@ -8956,7 +8955,7 @@ body.flow-editor-open { gap: 10px; } - .dashboard-thermostat-section .dashboard-section-head.compact-head > div:first-child { + .dashboard-thermostat-section .dashboard-section-head.compact-head>div:first-child { min-width: 0; text-align: left; } @@ -9000,7 +8999,6 @@ body.flow-editor-open { min-width: 0; } -/* Phones in landscape can cross desktop width breakpoints. Keep navigation compact when height is phone-like. */ @media (orientation: landscape) and (max-height: 640px) and (max-width: 1400px) { body:not(.flow-editor-open) main { width: 100%; @@ -9087,7 +9085,7 @@ body.flow-editor-open { gap: 4px; } - .flow-zoom-controls > span { + .flow-zoom-controls>span { display: none; } @@ -9121,7 +9119,6 @@ body.flow-editor-open { } } -/* Mobile UX correction pass: explicit left alignment, compact Flow fit control, natural chart fullscreen. */ @media (max-width: 700px) { #controlPlanSection .dashboard-section-head.compact-head { display: block; @@ -9130,7 +9127,7 @@ body.flow-editor-open { text-align: left; } - #controlPlanSection .dashboard-section-head.compact-head > div:first-child { + #controlPlanSection .dashboard-section-head.compact-head>div:first-child { width: 100%; min-width: 0; text-align: left; @@ -9165,7 +9162,7 @@ body.flow-editor-open { gap: 4px; } - .flow-zoom-controls > span { + .flow-zoom-controls>span { display: none; } @@ -9214,7 +9211,7 @@ body.flow-editor-open { gap: 4px; } - .flow-zoom-controls > span { + .flow-zoom-controls>span { display: none; } @@ -9240,7 +9237,6 @@ body.flow-editor-open { } } -/* In phone landscape there is enough width: keep the full Fit label instead of clipping it. */ @media (orientation: landscape) and (max-height: 700px) and (min-width: 500px) { .flow-fit-button { display: inline-flex; @@ -9258,5 +9254,4 @@ body.flow-editor-open { display: none; content: none; } -} - +} \ No newline at end of file