26 Commits
Author SHA1 Message Date
Fedorov Dmitriy fdb798a430 work : виджет стал более "горизонтальным" 2026-09-20 11:28:48 +03:00
Fedorov Dmitriy 33cdb205b3 Merge branch 'release' into dev_fedorov 2026-09-20 02:02:34 +03:00
Fedorov Dmitriy 83d2eb619c work : v1.2.0 добавил web в отслеживание 2026-09-20 02:02:21 +03:00
Fedorov Dmitriy 8069ab65f2 work : добавил web в отслеживание 2026-09-20 02:01:44 +03:00
Fedorov Dmitriy 455d191ea1 Merge branch 'release' into dev_fedorov 2026-09-20 02:00:40 +03:00
Fedorov Dmitriy 8f749da14c feat: v.1.2.0 добавлин web интерфейс. 2026-09-20 02:00:28 +03:00
Fedorov Dmitriy 0172b6bcf2 work: добавлин web интерфейс. 2026-09-20 01:59:36 +03:00
Fedorov Dmitriy 65cf547c6c Merge branch 'release' into dev_fedorov 2026-09-19 23:05:03 +03:00
Fedorov Dmitriy 3cd71ba5ff patch: v1.1.1 исправил порядок объявления классов в app/auto/config TemperatureConfig объявлялся после AutoConfig в котором использовался. 2026-09-19 23:04:52 +03:00
Fedorov Dmitriy 741be479a6 work: исправил порядок объявления классов в app/auto/config TemperatureConfig объявлялся после AutoConfig в котором использовался. 2026-09-19 22:51:36 +03:00
Fedorov Dmitriy 958691a481 doc: поправил зависимости и расписание 2026-09-19 19:14:08 +03:00
Fedorov Dmitriy a5c9d07db5 feat: v.1.1.0 work: добавил режим ручного управления. Теперь можно отключать расписание!
POST api/schedule/pause
POST api/schedule/resume
2026-09-19 19:05:55 +03:00
Fedorov Dmitriy c22e8bc71d work: добавил режим ручного управления. Теперь можно отключать расписание! 2026-09-19 19:04:45 +03:00
Fedorov Dmitriy b02d18d283 v. 1.0.0 Реализовано ручное и автоматическое управление бризером. 2026-09-19 18:26:08 +03:00
Fedorov Dmitriy c6d92c10ee work: Добваил нагрев в автоматизацию 2026-09-19 18:09:03 +03:00
Fedorov Dmitriy bff8efb85b work: Добавил команду для перехода в автоматический режим после установления скорости вручную 2026-09-19 16:20:20 +03:00
Fedorov Dmitriy f98cc2d6a3 work: Автоматический режим управления скоростью
отлажен!
2026-09-19 15:48:44 +03:00
Fedorov Dmitriy cdec9b0996 work: добавил автоматический режим 2026-09-18 23:59:42 +03:00
Fedorov Dmitriy 7dbfd8b069 work: добавил api для увеличения уменьшения температуры. 2026-09-15 21:41:02 +03:00
Fedorov Dmitriy a3dafb4f59 work: Рефакторинг API с расписанием и ручным управлением. 2026-09-14 22:12:02 +03:00
Fedorov Dmitriy dec14b1d12 work: Протестировано расписание и ручная установка значений во время работающего расписания. 2026-09-14 21:24:22 +03:00
Fedorov Dmitriy 4c665d9108 work: Реализована работа расписания. Проведен первый тест. 2026-09-13 23:04:35 +03:00
Fedorov Dmitriy 419b8efe33 work: Реализован API 2026-09-13 22:05:31 +03:00
Fedorov Dmitriy fdae9dec72 work: Отладил переподключение к Tion. Раньше при разрыве соединения в библиотеке btle соединение зависало. Сейчас при сбросе устройства явно вызывается disconnect библиотеки, чтобы сессия Bluetooth освобождалась. 2026-09-13 21:35:37 +03:00
Fedorov Dmitriy 5cc125db3f work: Полностью реализовал логику управления бризером. 2026-09-13 19:39:07 +03:00
Fedorov Dmitriy 9241da63c3 work: проервил работу bluetooth. Все работает. 2026-09-13 19:12:35 +03:00
19 changed files with 3294 additions and 22 deletions
+8
View File
@@ -0,0 +1,8 @@
[version]
major = 1
minor = 2
patch = 1
[build]
date = "2026-09-20"
time = "11:28:15"
+112 -1
View File
@@ -1,9 +1,12 @@
import logging
import shutil
from contextlib import asynccontextmanager
from typing import Literal, Annotated
import yaml
from fastapi import FastAPI, Request, HTTPException, Path
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pathlib import Path as FilePath
from app.qingping.service import QingpingService
@@ -184,6 +187,34 @@ app = FastAPI(
lifespan=lifespan,
)
WEB_ROOT = FilePath(__file__).resolve().parents[1] / "web"
app.mount(
"/ui/static",
StaticFiles(directory=WEB_ROOT),
name="ui-static",
)
@app.get("/", include_in_schema=False)
async def root():
return RedirectResponse(url="/ui/panel")
@app.get("/ui", include_in_schema=False)
async def ui_root():
return RedirectResponse(url="/ui/panel")
@app.get("/ui/widget", include_in_schema=False)
async def ui_widget():
return FileResponse(WEB_ROOT / "widget.html")
@app.get("/ui/panel", include_in_schema=False)
async def ui_panel():
return FileResponse(WEB_ROOT / "panel.html")
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
@@ -724,6 +755,86 @@ async def set_light(
# ----------------------------------------------------------------------
# Переход в auto
# ----------------------------------------------------------------------
@app.get("/api/schedule/config")
async def get_schedule_config():
try:
raw = yaml.safe_load(
FilePath(SCHEDULE_FILE).read_text(encoding="utf-8")
)
except (OSError, yaml.YAMLError) as exc:
raise HTTPException(
status_code=500,
detail={
"message": "Could not read schedule config",
"error": f"{type(exc).__name__}: {exc}",
},
) from exc
if not isinstance(raw, dict):
raise HTTPException(
status_code=500,
detail="Schedule config root must be an object",
)
return raw
@app.put("/api/schedule/config")
async def update_schedule_config(payload: dict):
global schedule_load_error
schedule_path = FilePath(SCHEDULE_FILE)
temp_path = schedule_path.with_name(schedule_path.name + ".tmp")
backup_path = schedule_path.with_name(schedule_path.name + ".bak")
try:
serialized = yaml.safe_dump(
payload,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
)
temp_path.write_text(serialized, encoding="utf-8")
config = load_schedule(temp_path)
except (OSError, ValueError, yaml.YAMLError) as exc:
temp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=400,
detail={
"message": "Invalid schedule config",
"error": str(exc),
},
) from exc
try:
if schedule_path.exists():
shutil.copy2(schedule_path, backup_path)
temp_path.replace(schedule_path)
except OSError as exc:
temp_path.unlink(missing_ok=True)
raise HTTPException(
status_code=500,
detail={
"message": "Could not save schedule config",
"error": f"{type(exc).__name__}: {exc}",
},
) from exc
schedule_load_error = None
restart_required = schedule_service is None
if schedule_service is not None:
await schedule_service.replace_config(config)
return {
"ok": True,
"restart_required": restart_required,
"config": payload,
"schedule": get_schedule_status(),
}
@app.post("/api/schedule/override/clear")
async def clear_schedule_override():
if schedule_service is None:
+6 -7
View File
@@ -23,6 +23,12 @@ class Co2Config:
...
]
@dataclass(
frozen=True,
slots=True,
)
class TemperatureConfig:
hysteresis: float
@dataclass(
frozen=True,
@@ -34,13 +40,6 @@ class AutoConfig:
co2: Co2Config
temperature: TemperatureConfig
@dataclass(
frozen=True,
slots=True,
)
class TemperatureConfig:
hysteresis: float
def _require_dict(name: str, value: Any) -> dict:
+5
View File
@@ -51,6 +51,11 @@ templates:
speed: 2
target_temp: 25
- time: "19:02"
action:
type: set
speed: 6
- time: "22:00"
action:
type: set
+20 -11
View File
@@ -1,16 +1,25 @@
# Это пример Python скрипта.
import argparse
# Нажмите Shift+F10 для выполнения или замените его своим кодом.
# Нажмите Двойное нажатие Shift для поиска везде: классы, файлы, окна инструментов, действия и настройки.
import uvicorn
def print_hi(name):
# Используйте точку останова в строке кода ниже для отладки скрипта.
print(f'Hi, {name}') # Нажмите Ctrl+F8 для переключения точки останова.
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Tion Controller web server")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", default=8000, type=int)
parser.add_argument(
"--reload",
action="store_true",
help="Reload the server after source file changes",
)
return parser.parse_args()
# Нажмите зеленую кнопку на полях для запуска скрипта.
if __name__ == '__main__':
print_hi('PyCharm')
# Справка PyCharm доступна на https://www.jetbrains.com/help/pycharm/
if __name__ == "__main__":
args = parse_args()
uvicorn.run(
"app.api:app",
host=args.host,
port=args.port,
reload=args.reload,
)
-1
View File
@@ -3,4 +3,3 @@ fastapi
uvicorn[standard]
PyYAML
paho-mqtt>=2.1,<3
paho-mqtt
+21
View File
@@ -105,6 +105,27 @@ class ScheduleService:
def config(self) -> ScheduleConfig:
return self._config
async def replace_config(
self,
config: ScheduleConfig,
) -> None:
"""Заменить расписание без перезапуска приложения."""
if not isinstance(config, ScheduleConfig):
raise TypeError("config must be ScheduleConfig")
self._config = config
self._last_applied_when = None
self._clear_override()
self._last_error = None
# Сохранение расписания не должно завершаться ошибкой только из-за
# временно недоступного Bluetooth. Фоновый цикл повторит применение.
if self._running and not self._paused:
try:
await self._process()
except Exception as exc:
self._last_error = f"{type(exc).__name__}: {exc}"
def _load_runtime_state(self) -> None:
if self._state_path is None:
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime
from pathlib import Path
import subprocess
import tomllib
PROJECT_DIR = Path(__file__).resolve().parent.parent
VERSION_FILE = PROJECT_DIR / "version.toml"
def get_git_commit() -> str:
"""Возвращает short hash текущего HEAD."""
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=PROJECT_DIR,
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return "unknown"
def main() -> None:
# Сначала читаем текущий version.toml.
# Нам нужны значения, которые разработчик поменял вручную.
with VERSION_FILE.open("rb") as file:
data = tomllib.load(file)
major = data["version"]["major"]
minor = data["version"]["minor"]
patch = data["version"]["patch"]
now = datetime.now()
content = f"""\
[version]
major = {major}
minor = {minor}
patch = {patch}
[build]
date = "{now:%Y-%m-%d}"
time = "{now:%H:%M:%S}"
"""
VERSION_FILE.write_text(
content,
encoding="utf-8",
)
print(
f"Version updated: "
f"{major}.{minor}.{patch} "
f"({now:%Y-%m-%d %H:%M:%S})"
)
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="fan" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="2.1" fill="currentColor" stroke="none"/>
<path d="M12 9.9c-1.3-1.6-2.1-3.1-1.3-5.1.6-1.5 2.5-2.2 3.8-1.2 1.7 1.3 1.2 3.5-.5 5.1-.6.6-1.3 1-2 1.2ZM14.1 12c1.6-1.3 3.1-2.1 5.1-1.3 1.5.6 2.2 2.5 1.2 3.8-1.3 1.7-3.5 1.2-5.1-.5-.6-.6-1-1.3-1.2-2ZM12 14.1c1.3 1.6 2.1 3.1 1.3 5.1-.6 1.5-2.5 2.2-3.8 1.2-1.7-1.3-1.2-3.5.5-5.1.6-.6 1.3-1 2-1.2ZM9.9 12c-1.6 1.3-3.1 2.1-5.1 1.3-1.5-.6-2.2-2.5-1.2-3.8 1.3-1.7 3.5-1.2 5.1.5.6.6 1 1.3 1.2 2Z" fill="currentColor" stroke="none"/>
</symbol>
<symbol id="thermometer" viewBox="0 0 24 24">
<path d="M9 14.4V5a3 3 0 0 1 6 0v9.4a5 5 0 1 1-6 0Z"/>
<path d="M12 7v9"/><circle cx="12" cy="18" r="1.7" fill="currentColor" stroke="none"/>
</symbol>
<symbol id="power" viewBox="0 0 24 24">
<path d="M12 2.8v8.4"/><path d="M7.2 6.1a8 8 0 1 0 9.6 0"/>
</symbol>
<symbol id="heat" viewBox="0 0 24 24">
<path d="M7 21c-2.7-3.1 2.5-5.5 0-8.6C4.7 9.6 8.8 7.1 7.2 3M12 21c-2.7-3.1 2.5-5.5 0-8.6C9.7 9.6 13.8 7.1 12.2 3M17 21c-2.7-3.1 2.5-5.5 0-8.6-2.3-2.8 1.8-5.3.2-9.4"/>
</symbol>
<symbol id="leaf" viewBox="0 0 24 24">
<path d="M20.5 3.5C12 3.3 5.5 6.4 5.2 13.1c-.2 3.5 2.5 6.1 5.8 5.7 6.3-.8 8.6-7.5 9.5-15.3Z"/>
<path d="M3 21c3.1-5 7-8.4 12.1-11"/>
</symbol>
<symbol id="drop" viewBox="0 0 24 24">
<path d="M12 2.7S5.7 9.6 5.7 14.8a6.3 6.3 0 0 0 12.6 0C18.3 9.6 12 2.7 12 2.7Z"/><path d="M9 16.2a3.3 3.3 0 0 0 3 2"/>
</symbol>
<symbol id="particles" viewBox="0 0 24 24">
<g fill="currentColor" stroke="none"><circle cx="7" cy="5" r="1.3"/><circle cx="13" cy="4" r="1"/><circle cx="18" cy="7" r="1.5"/><circle cx="5" cy="11" r="1"/><circle cx="11" cy="10" r="1.5"/><circle cx="17" cy="13" r="1"/><circle cx="7" cy="17" r="1.5"/><circle cx="13" cy="19" r="1"/><circle cx="19" cy="19" r="1.3"/></g>
</symbol>
<symbol id="filter" viewBox="0 0 24 24">
<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 7v10M12 7v10M15 7v10"/>
</symbol>
<symbol id="clock" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>
</symbol>
<symbol id="auto" viewBox="0 0 24 24">
<path d="M20 8V3.5L18.2 5A9 9 0 1 0 21 12M4 16v4.5L5.8 19"/><path d="m8.6 16 3.4-8 3.4 8M9.8 13h4.4"/>
</symbol>
<symbol id="pause" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"/><path d="M9.5 8v8M14.5 8v8"/>
</symbol>
<symbol id="settings" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"/>
</symbol>
<symbol id="home" viewBox="0 0 24 24">
<path d="m3 11 9-8 9 8"/><path d="M5.5 9.5V21h13V9.5M9.5 21v-7h5v7"/>
</symbol>
<symbol id="wind" viewBox="0 0 24 24">
<path d="M3 7h9a3 3 0 1 0-2.6-4.5M3 12h15a3 3 0 1 1-2.7 4.3M3 17h7"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

+293
View File
@@ -0,0 +1,293 @@
:root {
color-scheme: light;
--page: #dcecf2;
--page-deep: #b7d4df;
--glass: rgba(244, 251, 253, 0.58);
--glass-strong: rgba(248, 253, 255, 0.76);
--glass-soft: rgba(255, 255, 255, 0.34);
--line: rgba(255, 255, 255, 0.72);
--line-soft: rgba(63, 104, 121, 0.14);
--text: #17313e;
--muted: #617b87;
--faint: #8ca0a9;
--blue: #198ed1;
--blue-deep: #096ca7;
--blue-soft: rgba(25, 142, 209, 0.14);
--green: #20a66a;
--green-soft: rgba(32, 166, 106, 0.14);
--orange: #e98a2c;
--orange-soft: rgba(233, 138, 44, 0.16);
--red: #d95665;
--red-soft: rgba(217, 86, 101, 0.14);
--yellow: #d7a823;
--shadow: 0 24px 70px rgba(42, 86, 105, 0.2), 0 2px 10px rgba(43, 82, 98, 0.08);
--shadow-soft: 0 10px 32px rgba(39, 80, 96, 0.12);
--radius-xl: 28px;
--radius-lg: 21px;
--radius-md: 16px;
--blur: 26px;
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: #0d161d;
--page-deep: #172b39;
--glass: rgba(24, 38, 49, 0.68);
--glass-strong: rgba(28, 44, 56, 0.86);
--glass-soft: rgba(255, 255, 255, 0.045);
--line: rgba(193, 226, 240, 0.16);
--line-soft: rgba(188, 220, 234, 0.1);
--text: #eff8fb;
--muted: #9cb4bf;
--faint: #6e8793;
--blue: #56b9f0;
--blue-deep: #178ccf;
--blue-soft: rgba(69, 168, 225, 0.14);
--green: #52d394;
--green-soft: rgba(48, 193, 119, 0.14);
--orange: #f2a24c;
--orange-soft: rgba(242, 162, 76, 0.15);
--red: #ff7280;
--red-soft: rgba(255, 99, 116, 0.14);
--yellow: #f2c84b;
--shadow: 0 28px 80px rgba(0, 0, 0, 0.36), 0 2px 12px rgba(0, 0, 0, 0.28);
--shadow-soft: 0 12px 34px rgba(0, 0, 0, 0.22);
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 10% 5%, rgba(64, 176, 219, 0.32), transparent 32rem),
radial-gradient(circle at 88% 16%, rgba(73, 144, 225, 0.23), transparent 29rem),
radial-gradient(circle at 58% 92%, rgba(75, 191, 171, 0.2), transparent 32rem),
linear-gradient(145deg, var(--page), var(--page-deep));
background-attachment: fixed;
-webkit-font-smoothing: antialiased;
}
button, input { font: inherit; }
button { color: inherit; }
.ambient {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.ambient::before,
.ambient::after {
content: "";
position: absolute;
width: 28rem;
height: 28rem;
border-radius: 50%;
filter: blur(34px);
opacity: 0.34;
}
.ambient::before { left: -10rem; top: -12rem; background: #62d7e3; }
.ambient::after { right: -12rem; bottom: -15rem; background: #58a0ef; }
.glass {
background: linear-gradient(145deg, var(--glass-strong), var(--glass));
border: 1px solid var(--line);
box-shadow: var(--shadow);
backdrop-filter: blur(var(--blur)) saturate(135%);
-webkit-backdrop-filter: blur(var(--blur)) saturate(135%);
}
.glass-inner {
background: linear-gradient(145deg, var(--glass-soft), rgba(255,255,255,0.015));
border: 1px solid var(--line-soft);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), var(--shadow-soft);
}
.eyebrow {
color: var(--muted);
font-size: 0.69rem;
font-weight: 750;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.brand { display: flex; align-items: center; gap: 0.78rem; min-width: 0; }
.brand-mark {
display: grid;
place-items: center;
width: 2.65rem;
height: 2.65rem;
flex: 0 0 auto;
border-radius: 0.9rem;
color: white;
background: linear-gradient(145deg, #45b9ed, #147cba);
box-shadow: 0 8px 20px rgba(18, 126, 184, 0.26), inset 0 1px 0 rgba(255,255,255,0.35);
}
.brand-mark svg { width: 1.42rem; height: 1.42rem; }
.brand-copy { min-width: 0; }
.brand-title { margin: 0; font-size: 1.05rem; line-height: 1.15; letter-spacing: -0.015em; }
.brand-subtitle { margin: 0.18rem 0 0; color: var(--muted); font-size: 0.72rem; }
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.43rem;
min-height: 1.75rem;
padding: 0.3rem 0.66rem;
border: 1px solid transparent;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.055em;
white-space: nowrap;
text-transform: uppercase;
}
.pill::before { content: ""; width: 0.43rem; height: 0.43rem; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 4px currentColor; opacity: 0.9; }
.pill.good { color: var(--green); background: var(--green-soft); border-color: color-mix(in srgb, var(--green) 24%, transparent); }
.pill.info { color: var(--blue); background: var(--blue-soft); border-color: color-mix(in srgb, var(--blue) 22%, transparent); }
.pill.warn { color: var(--orange); background: var(--orange-soft); border-color: color-mix(in srgb, var(--orange) 24%, transparent); }
.pill.bad { color: var(--red); background: var(--red-soft); border-color: color-mix(in srgb, var(--red) 24%, transparent); }
.pill.muted { color: var(--muted); background: var(--glass-soft); border-color: var(--line-soft); }
.pill.no-dot::before { display: none; }
.icon-button, .soft-button, .primary-button, .step-button, .toggle, .segment {
border: 0;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease, border-color 150ms ease, opacity 150ms ease, box-shadow 150ms ease;
}
.icon-button:hover, .soft-button:hover, .primary-button:hover, .step-button:hover, .toggle:hover, .segment:hover { transform: translateY(-1px); }
.icon-button:active, .soft-button:active, .primary-button:active, .step-button:active, .toggle:active, .segment:active { transform: translateY(0) scale(0.98); }
button:focus-visible, input:focus-visible { outline: 3px solid color-mix(in srgb, var(--blue) 32%, transparent); outline-offset: 2px; }
button:disabled { cursor: wait; opacity: 0.55; transform: none !important; }
.icon-button {
display: grid;
place-items: center;
width: 2.45rem;
height: 2.45rem;
border: 1px solid var(--line-soft);
border-radius: 0.82rem;
background: var(--glass-soft);
}
.icon-button svg { width: 1.12rem; height: 1.12rem; }
.soft-button, .primary-button {
min-height: 2.55rem;
padding: 0.65rem 1rem;
border-radius: 0.88rem;
font-size: 0.77rem;
font-weight: 750;
}
.soft-button { color: var(--text); background: var(--glass-soft); border: 1px solid var(--line-soft); }
.primary-button { color: white; background: linear-gradient(145deg, #35aae3, #157dbb); box-shadow: 0 9px 24px rgba(14, 119, 177, 0.25); }
.soft-button.danger { color: var(--red); background: var(--red-soft); border-color: color-mix(in srgb, var(--red) 22%, transparent); }
.stepper { display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
.step-button {
display: grid;
place-items: center;
width: 2.28rem;
height: 2.28rem;
border-radius: 0.78rem;
color: var(--blue);
background: var(--blue-soft);
border: 1px solid color-mix(in srgb, var(--blue) 22%, transparent);
font-size: 1.25rem;
font-weight: 500;
line-height: 1;
}
.toggle {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
min-width: 5.5rem;
padding: 0.48rem 0.58rem 0.48rem 0.7rem;
border: 1px solid var(--line-soft);
border-radius: 999px;
background: var(--glass-soft);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.04em;
}
.toggle::after {
content: "";
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--faint);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.3);
}
.toggle[aria-pressed="true"] { color: var(--green); background: var(--green-soft); border-color: color-mix(in srgb, var(--green) 26%, transparent); }
.toggle[aria-pressed="true"]::after { background: var(--green); box-shadow: 0 0 14px color-mix(in srgb, var(--green) 52%, transparent); }
.toggle.orange[aria-pressed="true"] { color: var(--orange); background: var(--orange-soft); border-color: color-mix(in srgb, var(--orange) 28%, transparent); }
.toggle.orange[aria-pressed="true"]::after { background: var(--orange); box-shadow: 0 0 14px color-mix(in srgb, var(--orange) 48%, transparent); }
.value { font-variant-numeric: tabular-nums; }
.muted { color: var(--muted); }
.range {
--progress: 50%;
width: 100%;
height: 1.35rem;
margin: 0;
appearance: none;
background: transparent;
cursor: pointer;
}
.range::-webkit-slider-runnable-track {
height: 0.42rem;
border-radius: 99px;
background: linear-gradient(90deg, var(--blue) var(--progress), var(--line-soft) var(--progress));
}
.range::-moz-range-track { height: 0.42rem; border-radius: 99px; background: var(--line-soft); }
.range::-moz-range-progress { height: 0.42rem; border-radius: 99px; background: var(--blue); }
.range::-webkit-slider-thumb {
width: 1.35rem;
height: 1.35rem;
margin-top: -0.465rem;
appearance: none;
border: 0.26rem solid var(--glass-strong);
border-radius: 50%;
background: var(--blue);
box-shadow: 0 3px 12px rgba(14, 118, 177, 0.36);
}
.range::-moz-range-thumb {
width: 0.92rem;
height: 0.92rem;
border: 0.25rem solid var(--glass-strong);
border-radius: 50%;
background: var(--blue);
box-shadow: 0 3px 12px rgba(14, 118, 177, 0.36);
}
.toast-region { position: fixed; right: 1rem; bottom: 1rem; z-index: 50; display: grid; gap: 0.55rem; }
.toast {
max-width: min(23rem, calc(100vw - 2rem));
padding: 0.78rem 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
background: var(--glass-strong);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
font-size: 0.78rem;
animation: toast-in 180ms ease both;
}
.toast.error { color: var(--red); border-color: color-mix(in srgb, var(--red) 28%, transparent); }
@keyframes toast-in { from { transform: translateY(0.55rem); opacity: 0; } }
.skeleton { color: transparent !important; border-radius: 0.4rem; background: linear-gradient(100deg, var(--line-soft) 35%, var(--glass-soft) 50%, var(--line-soft) 65%); background-size: 200% 100%; animation: shimmer 1.3s infinite; }
@keyframes shimmer { to { background-position-x: -200%; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
+872
View File
@@ -0,0 +1,872 @@
:root {
--panel-ink: #08265d;
--panel-muted: #315d91;
--tile: rgba(219, 241, 253, 0.22);
--tile-strong: rgba(244, 251, 255, 0.4);
--tile-line: rgba(255, 255, 255, 0.9);
--tile-line-soft: rgba(193, 226, 244, 0.5);
--tile-shadow: 0 14px 32px rgba(23, 75, 111, 0.16), inset 0 1px 0 rgba(255,255,255,0.92), inset 1px 0 0 rgba(255,255,255,0.36);
--panel-glass: rgba(211, 235, 248, 0.35);
--panel-edge: rgba(255, 255, 255, 0.92);
--blue-halo: rgba(25, 139, 255, 0.34);
--green-halo: rgba(30, 205, 120, 0.34);
--orange-halo: rgba(255, 126, 46, 0.3);
}
:root[data-theme="dark"] {
--panel-ink: #edf8ff;
--panel-muted: #9fc5df;
--tile: rgba(28, 48, 65, 0.46);
--tile-strong: rgba(45, 71, 89, 0.54);
--tile-line: rgba(194, 229, 247, 0.3);
--tile-line-soft: rgba(114, 176, 207, 0.22);
--tile-shadow: 0 16px 38px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(235,249,255,0.14), inset 1px 0 0 rgba(190,228,247,0.08);
--panel-glass: rgba(13, 29, 41, 0.58);
--panel-edge: rgba(187, 225, 244, 0.32);
--blue-halo: rgba(30, 161, 255, 0.42);
--green-halo: rgba(25, 225, 129, 0.35);
--orange-halo: rgba(255, 126, 39, 0.34);
}
html { min-width: 320px; }
body {
min-height: 100vh;
padding: clamp(0.7rem, 2vw, 2rem);
color: var(--panel-ink);
background:
radial-gradient(ellipse at 8% 10%, rgba(244,253,255,.95) 0 7%, transparent 24%),
radial-gradient(ellipse at 73% -8%, rgba(255,249,239,.85) 0 10%, transparent 29%),
radial-gradient(ellipse at 93% 32%, rgba(90,152,125,.52) 0 10%, transparent 28%),
radial-gradient(ellipse at 12% 80%, rgba(65,132,170,.46) 0 12%, transparent 31%),
linear-gradient(115deg, rgba(184,222,239,.94), rgba(213,236,247,.82) 37%, rgba(239,232,217,.82) 66%, rgba(129,181,183,.88));
background-attachment: fixed;
overflow-x: hidden;
isolation: isolate;
}
:root[data-theme="dark"] body {
background:
radial-gradient(ellipse at 6% 2%, rgba(54,112,145,.44) 0 8%, transparent 28%),
radial-gradient(ellipse at 78% -5%, rgba(138,103,60,.31) 0 9%, transparent 28%),
radial-gradient(ellipse at 94% 36%, rgba(28,91,78,.44) 0 12%, transparent 31%),
radial-gradient(ellipse at 15% 87%, rgba(17,84,122,.42) 0 13%, transparent 33%),
linear-gradient(118deg, #081723, #122d3d 40%, #292c30 68%, #0c262c);
}
body::before {
content: "";
position: fixed;
inset: -7rem;
z-index: -3;
background:
linear-gradient(90deg, transparent 0 13%, rgba(255,255,255,.55) 13% 16%, transparent 16% 34%, rgba(255,255,255,.35) 34% 37%, transparent 37% 68%, rgba(255,255,255,.42) 68% 71%, transparent 71%),
linear-gradient(0deg, transparent 0 58%, rgba(228,244,251,.48) 58% 61%, transparent 61%),
radial-gradient(ellipse at 50% 114%, rgba(36,111,68,.48) 0 17%, transparent 47%);
filter: blur(26px);
transform: scale(1.05);
}
body::after {
content: "";
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
background:
radial-gradient(circle at 4% 22%, rgba(255,255,255,.88) 0 .8%, transparent 5%),
radial-gradient(circle at 19% 74%, rgba(246,253,255,.68) 0 1.2%, transparent 7%),
radial-gradient(circle at 62% 34%, rgba(255,241,210,.63) 0 1.3%, transparent 8%),
radial-gradient(circle at 89% 13%, rgba(244,253,255,.66) 0 1%, transparent 6%),
radial-gradient(circle at 82% 82%, rgba(209,238,228,.48) 0 1.5%, transparent 8%);
filter: blur(8px);
opacity: .86;
}
.ambient::before { width: 42rem; height: 42rem; left: -14rem; top: -18rem; background: #c9f2ff; opacity: .58; filter: blur(54px); }
.ambient::after { width: 46rem; height: 46rem; right: -14rem; bottom: -21rem; background: #ebd5a7; opacity: .38; filter: blur(64px); }
:root[data-theme="dark"] .ambient::before { background: #147db5; opacity: .24; }
:root[data-theme="dark"] .ambient::after { background: #846630; opacity: .2; }
.climate-panel {
position: relative;
isolation: isolate;
overflow: hidden;
width: min(100%, 104rem);
min-height: calc(100vh - clamp(1.4rem, 4vw, 4rem));
margin: 0 auto;
padding: clamp(0.65rem, 1.25vw, 1.25rem);
border: 1px solid var(--panel-edge);
outline: 1px solid rgba(255,255,255,.5);
outline-offset: -5px;
border-radius: 1.5rem;
background:
linear-gradient(125deg, rgba(255,255,255,.34), transparent 29%, rgba(255,255,255,.11) 67%, rgba(214,235,247,.2)),
var(--panel-glass);
box-shadow: 0 32px 90px rgba(20,60,89,.3), 0 2px 10px rgba(255,255,255,.35), inset 0 1px 0 rgba(255,255,255,.95), inset 0 -1px 0 rgba(114,162,192,.23);
backdrop-filter: blur(34px) saturate(148%);
-webkit-backdrop-filter: blur(34px) saturate(148%);
}
:root[data-theme="dark"] .climate-panel { outline-color: rgba(203,235,250,.12); box-shadow: 0 38px 100px rgba(0,0,0,.48), 0 0 42px rgba(21,120,171,.1), inset 0 1px 0 rgba(224,246,255,.2), inset 0 -1px 0 rgba(0,0,0,.35); }
.climate-panel::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: inherit;
background:
linear-gradient(112deg, rgba(255,255,255,.44) 0, rgba(255,255,255,.08) 17%, transparent 38%),
radial-gradient(ellipse at 52% -16%, rgba(255,255,255,.54), transparent 48%),
radial-gradient(ellipse at 100% 100%, rgba(102,178,219,.13), transparent 42%);
}
.climate-panel::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: .2;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
mix-blend-mode: soft-light;
}
.climate-panel > * { position: relative; z-index: 1; }
.glass-tile, .glass-section {
position: relative;
isolation: isolate;
overflow: hidden;
border: 1px solid var(--tile-line);
outline: 1px solid var(--tile-line-soft);
outline-offset: -3px;
background:
linear-gradient(132deg, rgba(255,255,255,.38), rgba(255,255,255,.06) 35%, transparent 63%),
linear-gradient(145deg, var(--tile-strong), var(--tile));
box-shadow: var(--tile-shadow);
backdrop-filter: blur(23px) saturate(145%);
-webkit-backdrop-filter: blur(23px) saturate(145%);
}
.glass-tile::before, .glass-section::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: inherit;
background: linear-gradient(115deg, rgba(255,255,255,.35), transparent 25%, transparent 72%, rgba(255,255,255,.08));
}
.glass-tile > *, .glass-section > * { position: relative; z-index: 1; }
.speed-card::after, .temperature-card::after, .power-card::after, .heater-card::after {
content: "";
position: absolute;
z-index: 0;
width: 13rem;
height: 13rem;
right: -5rem;
bottom: -7rem;
border-radius: 50%;
pointer-events: none;
filter: blur(17px);
}
.speed-card::after { background: var(--blue-halo); }
.temperature-card::after { width: 18rem; background: linear-gradient(90deg, var(--blue-halo), var(--orange-halo)); }
.power-card::after { background: var(--green-halo); opacity: .64; }
.heater-card::after { background: var(--orange-halo); opacity: .58; }
.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0 0.65rem 0.7rem; }
.identity, .header-tools { display: flex; align-items: center; gap: 0.65rem; }
.identity h1 { margin: 0 0.35rem 0 0; font-size: clamp(1.8rem, 3.1vw, 3rem); line-height: 1; letter-spacing: -0.045em; }
.climate-panel .pill { min-height: 2.05rem; padding: .38rem .82rem; border-color: rgba(255,255,255,.62); box-shadow: inset 0 1px 0 rgba(255,255,255,.72), 0 6px 18px rgba(26,75,108,.1); backdrop-filter: blur(14px); }
.climate-panel .pill.good { box-shadow: 0 0 21px rgba(28,204,117,.18), inset 0 1px 0 rgba(255,255,255,.74); }
.climate-panel .pill.warn { box-shadow: 0 0 21px rgba(242,145,56,.17), inset 0 1px 0 rgba(255,255,255,.74); }
.climate-panel .pill.info { box-shadow: 0 0 21px rgba(31,145,236,.16), inset 0 1px 0 rgba(255,255,255,.74); }
.climate-panel .icon-button { border-color: var(--tile-line); background: linear-gradient(145deg, var(--tile-strong), var(--tile)); box-shadow: var(--tile-shadow); backdrop-filter: blur(16px); }
.header-tools { justify-content: flex-end; }
.ventilation-badge { display: flex; align-items: center; gap: 0.7rem; min-width: 22rem; padding: 0.55rem 1rem; border-radius: 1rem; }
.ventilation-badge svg { width: 2.5rem; color: #1977d2; filter: drop-shadow(0 0 9px rgba(26,128,226,.34)); }
.ventilation-badge strong, .ventilation-badge small { display: block; }
.ventilation-badge strong { font-size: 0.9rem; }
.ventilation-badge small { margin-top: 0.1rem; color: var(--panel-muted); font-size: 0.68rem; }
.primary-controls { display: grid; grid-template-columns: 2.05fr 2.05fr .95fr .95fr; gap: 0.75rem; }
.control-card, .switch-card { min-height: 17.6rem; border-radius: 1.15rem; padding: 1rem; }
.control-card { display: flex; flex-direction: column; }
.card-title { display: flex; align-items: flex-start; gap: 0.72rem; }
.card-title.compact { align-items: center; }
.card-title h2 { margin: 0; font-size: clamp(1rem, 1.55vw, 1.28rem); letter-spacing: -0.02em; }
.card-title p { margin: 0.18rem 0 0; color: var(--panel-muted); font-size: 0.72rem; }
.feature-icon { display: grid; place-items: center; flex: 0 0 auto; width: 2.1rem; height: 2.1rem; color: #1389f4; font-size: 1.75rem; font-weight: 800; line-height: 1; text-shadow: 0 0 16px rgba(20,139,244,.46); }
.feature-icon.green { color: #08a95a; text-shadow: 0 0 20px rgba(20,210,112,.62); }
.feature-icon.orange { color: #f06419; text-shadow: 0 0 18px rgba(255,105,29,.46); }
.fan-icon { font-size: 2.2rem; }
.hero-control { display: grid; grid-template-columns: 4.3rem 1fr 4.3rem; align-items: center; gap: 1rem; margin: auto 0 0.75rem; }
.hero-value { text-align: center; font-size: clamp(3.2rem, 5vw, 4.35rem); line-height: 1; letter-spacing: -0.06em; font-variant-numeric: tabular-nums; }
.hero-step {
display: grid;
place-items: center;
width: 4.3rem;
height: 4.3rem;
border: 1.5px solid rgba(255,255,255,.85);
border-radius: 1rem;
color: #176dbb;
background: linear-gradient(145deg, rgba(245,252,255,.42), rgba(191,224,244,.2));
box-shadow: 0 10px 24px rgba(35,92,132,.15), inset 0 1px 0 rgba(255,255,255,.9), 0 0 0 1px rgba(185,226,248,.24);
cursor: pointer;
font-size: 2.3rem;
font-weight: 500;
transition: transform .15s ease, filter .15s ease;
}
.hero-step.primary { color: white; background: linear-gradient(145deg, #57b5ff, #1376e4 76%); box-shadow: 0 12px 28px rgba(16,103,220,.35), 0 0 26px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.8), inset 0 -2px 5px rgba(0,67,156,.24); }
.hero-step:hover { transform: translateY(-2px); filter: brightness(1.08); box-shadow: 0 14px 32px rgba(25,112,219,.35), 0 0 32px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.78); }
.hero-step:active { transform: scale(.98); }
.speed-range { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); clip-path: inset(50%); white-space: nowrap; }
.speed-segments { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.16rem; }
.speed-segments button { height: 1.12rem; padding: 0; border: 1px solid rgba(255,255,255,.88); border-radius: .55rem; background: linear-gradient(180deg, rgba(173,207,229,.32), rgba(98,151,190,.2)); box-shadow: inset 0 1px 2px rgba(27,70,105,.08), 0 2px 5px rgba(32,86,126,.08); cursor: pointer; transition: background .12s ease, box-shadow .12s ease, transform .12s ease; }
.speed-segments button:hover { transform: translateY(-1px); }
.speed-segments button.active { background: linear-gradient(90deg, #0e82ed, #37b8ff); box-shadow: 0 0 17px rgba(25,143,247,.56), inset 0 1px 0 rgba(255,255,255,.74), inset 0 -2px 4px rgba(0,84,182,.22); }
.speed-labels, .temperature-labels { display: flex; justify-content: space-between; margin-top: 0.45rem; padding: 0 .55rem; color: var(--panel-muted); font-size: 0.68rem; }
.temperature-range { height: 1.45rem; }
.temperature-range::-webkit-slider-runnable-track { height: .78rem; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); border: 1px solid rgba(255,255,255,.92); box-shadow: 0 0 16px rgba(37,153,239,.24), 0 0 14px rgba(255,114,41,.14), inset 0 1px 2px rgba(255,255,255,.5); }
.temperature-range::-webkit-slider-thumb { width: 1.9rem; height: 1.9rem; margin-top: -.58rem; border-width: .28rem; box-shadow: 0 0 0 2px rgba(255,255,255,.55), 0 0 22px rgba(29,139,241,.58), 0 5px 13px rgba(17,79,143,.3); }
.temperature-range::-moz-range-track { height: .78rem; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); }
.temperature-range::-moz-range-progress { background: transparent; }
.switch-card { display: flex; flex-direction: column; justify-content: space-between; }
.glass-switch {
width: 100%; min-height: 6rem; border: 1.5px solid rgba(255,255,255,.9); border-radius: 1rem; color: var(--panel-ink); background: rgba(199,222,235,.28); box-shadow: var(--tile-shadow); cursor: pointer; font-size: 1.75rem; font-weight: 850;
text-shadow: 0 1px 0 rgba(255,255,255,.35);
transition: transform .15s ease, background .2s ease, box-shadow .2s ease, filter .2s ease;
}
.glass-switch:hover { transform: translateY(-2px); filter: brightness(1.06); }
.glass-switch[aria-pressed="true"].green { background: linear-gradient(145deg, rgba(145,255,198,.9), rgba(38,194,113,.62)); border-color: rgba(213,255,232,.96); box-shadow: 0 14px 32px rgba(11,153,81,.3), 0 0 32px var(--green-halo), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 10px rgba(0,125,62,.18); }
.glass-switch[aria-pressed="true"].orange { background: linear-gradient(145deg, rgba(255,202,130,.88), rgba(245,112,40,.58)); border-color: rgba(255,236,213,.95); box-shadow: 0 14px 32px rgba(210,91,25,.3), 0 0 30px var(--orange-halo), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -4px 10px rgba(168,54,0,.17); }
.content-section { margin-top: 0.75rem; padding: 0.7rem; border-radius: 1.15rem; }
.section-title { display: flex; align-items: center; gap: 0.65rem; min-height: 2.35rem; padding: 0 0.35rem 0.5rem; }
.section-title > div { display: flex; align-items: center; gap: 0.55rem; }
.section-title h2 { margin: 0; font-size: clamp(1rem, 1.55vw, 1.28rem); }
.section-icon { display: grid; place-items: center; width: 1.8rem; height: 1.8rem; color: #207bcc; font-size: 1.55rem; text-shadow: 0 0 14px rgba(26,124,210,.38); }
.section-icon.leaf { color: #069c51; transform: rotate(-18deg); text-shadow: 0 0 15px rgba(8,173,88,.42); }
.section-title .updated { margin-left: auto; color: var(--panel-muted); font-size: 0.7rem; }
.air-grid { display: grid; grid-template-columns: 1.55fr 1fr .9fr 1fr 1fr 1.28fr; gap: 0.55rem; }
.metric-card { min-width: 0; min-height: 9.4rem; padding: 0.85rem; border-radius: 1rem; }
.metric-head, .metric-heading { display: flex; align-items: center; gap: 0.55rem; color: var(--panel-muted); font-size: 0.76rem; }
.metric-head { justify-content: space-between; }
.metric-icon { color: #137edc; font-size: 1.7rem; line-height: 1; text-shadow: 0 0 14px rgba(20,124,221,.34); }
.metric-icon.dots { letter-spacing: -.2rem; }
.metric-card > strong { display: block; margin-top: 0.75rem; font-size: clamp(1.55rem, 2.4vw, 2.2rem); line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }
.metric-card strong small { color: var(--panel-muted); font-size: .63rem; font-weight: 600; }
.quality-chip { display: inline-flex; justify-content: center; padding: .35rem .72rem; border: 1px solid rgba(109,237,167,.82); border-radius: 999px; color: #076d3d; background: linear-gradient(145deg, rgba(179,255,214,.7), rgba(92,218,151,.48)); box-shadow: 0 0 17px rgba(33,203,116,.24), inset 0 1px 0 rgba(255,255,255,.88); font-size: .66rem; font-weight: 750; }
:root[data-theme="dark"] .quality-chip { color: #b9ffda; }
.quality-chip.small { margin-top: .8rem; min-width: 5.5rem; }
.co2-card > p { height: 1rem; margin: .2rem 0 .55rem; color: var(--panel-muted); font-size: .58rem; overflow: hidden; }
.meter { height: .82rem; border: 1px solid rgba(255,255,255,.8); border-radius: 999px; background: rgba(91,145,181,.18); overflow: hidden; }
.meter i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, #08a350, #36dc83); box-shadow: 0 0 18px rgba(18,199,104,.58), inset 0 1px 0 rgba(255,255,255,.64); transition: width .35s ease; }
.meter-labels { display: flex; justify-content: space-between; margin-top: .27rem; color: rgba(22,69,108,.78); font-size: .6rem; font-weight: 650; text-shadow: 0 1px 0 rgba(255,255,255,.48); }
:root[data-theme="dark"] .meter-labels { color: rgba(211,235,248,.76); text-shadow: 0 1px 2px rgba(0,0,0,.58); }
.filter-line { display: flex; align-items: center; gap: .45rem; margin-top: 1rem; }
.filter-meter { flex: 1; }
.filter-meter i { background: linear-gradient(90deg, #176dec, #36c1ff); box-shadow: 0 0 18px rgba(31,144,247,.55), inset 0 1px 0 rgba(255,255,255,.58); }
.filter-line > span { font-size: .65rem; font-weight: 750; }
.bottom-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 0.75rem; }
.schedule-layout { display: grid; grid-template-columns: 1.35fr 1fr; gap: 0.7rem; }
.schedule-times { display: grid; grid-template-columns: 1fr 1px 1fr; gap: 1rem; align-items: center; min-height: 8.2rem; padding: 1rem 1.3rem; border-radius: 1rem; }
.schedule-times > i { width: 1px; height: 80%; background: rgba(49,93,145,.25); }
.schedule-times div { display: grid; gap: .22rem; }
.schedule-times span, .schedule-times small { color: var(--panel-muted); font-size: .68rem; }
.schedule-times strong { font-size: clamp(1.65rem, 2.8vw, 2.3rem); font-variant-numeric: tabular-nums; }
.schedule-buttons { display: grid; gap: .55rem; }
.schedule-button { display: grid; grid-template-columns: 2.4rem 1fr; align-items: center; gap: .65rem; min-height: 3.8rem; padding: .55rem .8rem; border: 1.5px solid; border-radius: .9rem; text-align: left; cursor: pointer; }
.schedule-button strong, .schedule-button small { display: block; }
.schedule-button strong { font-size: .84rem; }
.schedule-button small { margin-top: .12rem; font-size: .62rem; }
.schedule-button.auto { color: #096bda; border-color: rgba(83,176,255,.9); background: linear-gradient(145deg, rgba(224,244,255,.67), rgba(133,201,250,.26)); box-shadow: 0 9px 24px rgba(23,113,212,.19), 0 0 20px rgba(42,153,243,.13), inset 0 1px 0 rgba(255,255,255,.92); }
.schedule-button.pause { color: #a73512; border-color: rgba(250,174,102,.9); background: linear-gradient(145deg, rgba(255,240,217,.72), rgba(246,177,107,.28)); box-shadow: 0 9px 24px rgba(202,104,44,.17), 0 0 20px rgba(244,131,50,.12), inset 0 1px 0 rgba(255,255,255,.92); }
.schedule-button:hover { filter: brightness(1.06); transform: translateY(-1px); }
.action-symbol { font-size: 2rem; font-weight: 800; text-align: center; }
.mode-banner { display: flex; gap: .4rem; align-items: center; margin-top: .5rem; padding: .45rem .7rem; border-radius: .65rem; color: var(--panel-muted); background: rgba(255,255,255,.14); font-size: .62rem; }
.mode-banner small::before { content: "· "; }
.extras-grid { display: grid; grid-template-columns: .9fr 1.3fr; gap: .55rem; }
.extra-card { display: flex; align-items: center; gap: .8rem; min-height: 8.2rem; padding: 1rem; border-radius: 1rem; }
.extra-icon { color: #267fd3; font-size: 3.4rem; line-height: 1; text-shadow: 0 0 19px rgba(33,126,211,.32); }
.extra-icon.wind { font-size: 3.8rem; transform: rotate(90deg); }
.extra-card small, .extra-card strong, .extra-card em { display: block; }
.extra-card small { color: var(--panel-muted); font-size: .7rem; }
.extra-card strong { margin-top: .18rem; font-size: clamp(1.3rem, 2.2vw, 2rem); font-style: normal; }
.extra-card em { margin-top: .18rem; color: var(--panel-muted); font-size: .62rem; font-style: normal; }
.mode-card { display: grid; grid-template-columns: auto 1fr; }
.mode-card strong { font-size: .85rem; }
.segments { grid-column: 1 / -1; display: flex; gap: .3rem; }
.segment { flex: 1; padding: .42rem; border: 1px solid rgba(255,255,255,.55); border-radius: .55rem; color: var(--panel-muted); background: rgba(255,255,255,.14); cursor: pointer; font-size: .58rem; }
.segment.active { color: white; background: linear-gradient(145deg, #45a7f4, #1976d2); }
.micro-settings { display: flex; align-items: center; gap: .65rem; margin-top: .55rem; padding: 0 .25rem; color: var(--panel-muted); font-size: .58rem; }
.micro-settings > span:nth-child(2) { margin-right: auto; }
.micro-toggle { padding: .32rem .55rem; border: 1px solid var(--tile-line); border-radius: 999px; color: var(--panel-muted); background: var(--tile); cursor: pointer; font-size: .56rem; }
.micro-toggle[aria-pressed="true"] { color: #087848; background: rgba(105,222,160,.38); }
.demo-note { display: block; margin-top: .55rem; color: var(--panel-muted); text-align: center; font-size: .62rem; }
/* Dark aeroglass keeps the same optical depth, with colder edges and
brighter local halos so active controls do not disappear into the glass. */
:root[data-theme="dark"] body::after { opacity: .42; mix-blend-mode: screen; }
:root[data-theme="dark"] .climate-panel::before {
background:
linear-gradient(112deg, rgba(205,239,255,.14), rgba(255,255,255,.025) 21%, transparent 42%),
radial-gradient(ellipse at 52% -16%, rgba(112,202,244,.13), transparent 50%),
radial-gradient(ellipse at 100% 100%, rgba(23,139,186,.12), transparent 44%);
}
:root[data-theme="dark"] .glass-tile,
:root[data-theme="dark"] .glass-section {
background:
linear-gradient(132deg, rgba(211,240,253,.105), rgba(255,255,255,.018) 38%, transparent 65%),
linear-gradient(145deg, var(--tile-strong), var(--tile));
}
:root[data-theme="dark"] .glass-tile::before,
:root[data-theme="dark"] .glass-section::before {
background: linear-gradient(115deg, rgba(218,243,255,.12), transparent 26%, transparent 74%, rgba(80,181,222,.045));
}
:root[data-theme="dark"] .hero-step {
color: #8ed3ff;
border-color: rgba(184,226,247,.28);
background: linear-gradient(145deg, rgba(89,137,166,.2), rgba(20,48,67,.23));
box-shadow: 0 12px 28px rgba(0,0,0,.28), inset 0 1px 0 rgba(224,246,255,.14), 0 0 0 1px rgba(60,157,205,.08);
}
:root[data-theme="dark"] .hero-step.primary {
color: white;
background: linear-gradient(145deg, #42b8ff, #096bd3 78%);
box-shadow: 0 13px 32px rgba(0,65,139,.5), 0 0 30px var(--blue-halo), inset 0 1px 0 rgba(220,248,255,.52), inset 0 -3px 7px rgba(0,27,91,.38);
}
:root[data-theme="dark"] .speed-segments button { border-color: rgba(192,226,244,.25); background: linear-gradient(180deg, rgba(91,136,162,.23), rgba(24,55,74,.28)); }
:root[data-theme="dark"] .speed-segments button.active { border-color: rgba(117,207,255,.55); background: linear-gradient(90deg, #0877df, #20b7ff); box-shadow: 0 0 20px rgba(24,156,255,.66), inset 0 1px 0 rgba(223,248,255,.42); }
:root[data-theme="dark"] .glass-switch { color: #ddecf4; border-color: rgba(193,228,245,.25); background: linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); text-shadow: 0 1px 2px rgba(0,0,0,.4); }
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green { color: #eafff4; border-color: rgba(118,255,185,.54); background: linear-gradient(145deg, rgba(56,221,137,.76), rgba(8,120,69,.67)); box-shadow: 0 15px 36px rgba(0,0,0,.3), 0 0 38px var(--green-halo), inset 0 1px 0 rgba(218,255,236,.48); }
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { color: #fff5e9; border-color: rgba(255,185,118,.53); background: linear-gradient(145deg, rgba(244,145,61,.76), rgba(143,58,14,.68)); box-shadow: 0 15px 36px rgba(0,0,0,.3), 0 0 36px var(--orange-halo), inset 0 1px 0 rgba(255,237,216,.48); }
:root[data-theme="dark"] .quality-chip { color: #caffdf; border-color: rgba(89,232,158,.42); background: linear-gradient(145deg, rgba(45,186,113,.35), rgba(11,101,60,.3)); box-shadow: 0 0 20px rgba(20,218,118,.18), inset 0 1px 0 rgba(210,255,232,.16); }
:root[data-theme="dark"] .schedule-button.auto { color: #a8ddff; border-color: rgba(76,176,241,.48); background: linear-gradient(145deg, rgba(38,125,187,.29), rgba(15,61,91,.28)); box-shadow: 0 10px 27px rgba(0,0,0,.23), 0 0 24px rgba(27,150,237,.13), inset 0 1px 0 rgba(211,241,255,.13); }
:root[data-theme="dark"] .schedule-button.pause { color: #ffc18c; border-color: rgba(242,147,70,.48); background: linear-gradient(145deg, rgba(167,82,27,.3), rgba(73,40,25,.27)); box-shadow: 0 10px 27px rgba(0,0,0,.23), 0 0 24px rgba(244,117,35,.12), inset 0 1px 0 rgba(255,230,207,.12); }
:root[data-theme="dark"] .meter { border-color: rgba(200,232,247,.24); background: rgba(3,22,34,.34); box-shadow: inset 0 2px 5px rgba(0,0,0,.2); }
:root[data-theme="dark"] .micro-toggle { border-color: rgba(190,226,244,.2); background: rgba(76,117,141,.15); }
:root[data-theme="dark"] .micro-toggle[aria-pressed="true"] { color: #b8ffda; border-color: rgba(67,213,139,.35); background: rgba(21,149,85,.26); box-shadow: 0 0 15px rgba(20,208,114,.12); }
/* Photographic aeroglass refinement. The backdrop already contains optical
blur, so the UI only adds a restrained haze instead of whitening the scene. */
:root {
--tile: rgba(214, 238, 251, 0.14);
--tile-strong: rgba(247, 252, 255, 0.27);
--tile-line: rgba(255, 255, 255, 0.82);
--tile-line-soft: rgba(206, 235, 249, 0.43);
--panel-glass: rgba(200, 228, 244, 0.2);
--blue-halo: rgba(23, 137, 248, 0.16);
--green-halo: rgba(28, 196, 113, 0.16);
--orange-halo: rgba(246, 126, 50, 0.14);
}
:root[data-theme="dark"] {
--tile: rgba(16, 37, 51, 0.28);
--tile-strong: rgba(52, 78, 96, 0.31);
--tile-line: rgba(203, 234, 248, 0.25);
--tile-line-soft: rgba(114, 176, 207, 0.16);
--panel-glass: rgba(7, 22, 33, 0.48);
--blue-halo: rgba(29, 153, 245, 0.22);
--green-halo: rgba(27, 210, 121, 0.19);
--orange-halo: rgba(246, 125, 46, 0.18);
}
body {
background-color: #b7d5e3;
background-image: none;
}
:root[data-theme="dark"] body {
background-color: #091722;
background-image: none;
}
body::before {
inset: -1.25rem;
background-image:
linear-gradient(118deg, rgba(180,220,239,.15), rgba(238,247,251,.025) 45%, rgba(247,226,204,.09)),
url("../assets/mountain-lake-light.jpg");
background-position: center;
background-size: cover;
background-repeat: no-repeat;
filter: blur(6px) saturate(108%);
transform: scale(1.025);
opacity: 1;
pointer-events: none;
}
:root[data-theme="dark"] body::before {
background-image:
linear-gradient(118deg, rgba(2,15,25,.66), rgba(8,27,40,.57) 48%, rgba(20,25,29,.64)),
url("../assets/mountain-lake-dark.jpg");
background-position: center;
background-size: cover;
filter: blur(7px) saturate(106%);
opacity: 1;
}
body::after {
background:
radial-gradient(circle at 11% 18%, rgba(255,255,255,.33) 0 .5%, transparent 5%),
radial-gradient(circle at 64% 23%, rgba(255,244,218,.28) 0 .6%, transparent 6%),
radial-gradient(circle at 91% 79%, rgba(225,250,237,.22) 0 .8%, transparent 7%);
filter: blur(11px);
opacity: .5;
}
:root[data-theme="dark"] body::after { opacity: .26; }
.ambient::before { opacity: .14; filter: blur(76px); }
.ambient::after { opacity: .11; filter: blur(82px); }
:root[data-theme="dark"] .ambient::before { opacity: .12; }
:root[data-theme="dark"] .ambient::after { opacity: .08; }
.climate-panel {
background:
linear-gradient(125deg, rgba(255,255,255,.22), transparent 31%, rgba(255,255,255,.055) 69%, rgba(196,224,240,.1)),
var(--panel-glass);
box-shadow: 0 27px 68px rgba(22,66,95,.24), 0 2px 8px rgba(255,255,255,.28), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -1px 0 rgba(113,163,192,.17);
backdrop-filter: blur(13px) saturate(124%);
-webkit-backdrop-filter: blur(13px) saturate(124%);
}
.climate-panel::before {
background:
linear-gradient(112deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 18%, transparent 39%),
radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.25), transparent 47%);
}
.climate-panel::after { opacity: .1; }
:root[data-theme="dark"] .climate-panel { box-shadow: 0 33px 78px rgba(0,0,0,.43), 0 0 28px rgba(19,112,158,.06), inset 0 1px 0 rgba(221,243,252,.17), inset 0 -1px 0 rgba(0,0,0,.28); }
:root[data-theme="dark"] .climate-panel::before { background: linear-gradient(112deg, rgba(203,238,253,.1), transparent 24%, transparent 72%, rgba(71,163,202,.035)); }
.glass-tile, .glass-section {
background:
linear-gradient(132deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 35%, transparent 64%),
linear-gradient(145deg, var(--tile-strong), var(--tile));
box-shadow: 0 10px 25px rgba(24,72,105,.12), inset 0 1px 0 rgba(255,255,255,.78), inset 1px 0 0 rgba(255,255,255,.24);
backdrop-filter: blur(16px) saturate(127%);
-webkit-backdrop-filter: blur(16px) saturate(127%);
}
.glass-tile::before, .glass-section::before { opacity: .62; }
:root[data-theme="dark"] .glass-tile,
:root[data-theme="dark"] .glass-section {
background:
linear-gradient(132deg, rgba(213,240,252,.075), rgba(255,255,255,.012) 38%, transparent 65%),
linear-gradient(145deg, var(--tile-strong), var(--tile));
box-shadow: 0 13px 29px rgba(0,0,0,.26), inset 0 1px 0 rgba(226,246,255,.11), inset 1px 0 0 rgba(186,226,245,.055);
}
.speed-card::after, .temperature-card::after, .power-card::after, .heater-card::after { filter: blur(32px); opacity: .48; }
.power-card::after { opacity: .38; }
.heater-card::after { opacity: .34; }
.hero-step.primary { box-shadow: 0 10px 23px rgba(16,103,220,.25), 0 0 15px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.76), inset 0 -2px 5px rgba(0,67,156,.2); }
.hero-step:hover { box-shadow: 0 12px 27px rgba(25,112,219,.27), 0 0 19px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.75); }
.speed-segments button.active { box-shadow: 0 0 9px rgba(25,143,247,.34), inset 0 1px 0 rgba(255,255,255,.66), inset 0 -2px 4px rgba(0,84,182,.18); }
.temperature-range::-webkit-slider-runnable-track { box-shadow: 0 0 8px rgba(37,153,239,.15), 0 0 7px rgba(255,114,41,.09), inset 0 1px 2px rgba(255,255,255,.46); }
.temperature-range::-webkit-slider-thumb { box-shadow: 0 0 0 2px rgba(255,255,255,.48), 0 0 12px rgba(29,139,241,.34), 0 4px 10px rgba(17,79,143,.24); }
.glass-switch[aria-pressed="true"].green { box-shadow: 0 12px 27px rgba(11,153,81,.22), 0 0 19px var(--green-halo), inset 0 1px 0 rgba(255,255,255,.84), inset 0 -4px 10px rgba(0,125,62,.14); }
.glass-switch[aria-pressed="true"].orange { box-shadow: 0 12px 27px rgba(210,91,25,.2), 0 0 18px var(--orange-halo), inset 0 1px 0 rgba(255,255,255,.83), inset 0 -4px 10px rgba(168,54,0,.13); }
.quality-chip { box-shadow: 0 0 10px rgba(33,203,116,.13), inset 0 1px 0 rgba(255,255,255,.83); }
.meter i { box-shadow: 0 0 10px rgba(18,199,104,.35), inset 0 1px 0 rgba(255,255,255,.58); }
.filter-meter i { box-shadow: 0 0 10px rgba(31,144,247,.32), inset 0 1px 0 rgba(255,255,255,.54); }
.schedule-button.auto, .schedule-button.pause { box-shadow: 0 7px 18px rgba(31,83,119,.12), inset 0 1px 0 rgba(255,255,255,.82); }
:root[data-theme="dark"] .hero-step.primary { box-shadow: 0 11px 27px rgba(0,65,139,.4), 0 0 18px var(--blue-halo), inset 0 1px 0 rgba(220,248,255,.42), inset 0 -3px 7px rgba(0,27,91,.3); }
:root[data-theme="dark"] .speed-segments button.active { box-shadow: 0 0 12px rgba(24,156,255,.4), inset 0 1px 0 rgba(223,248,255,.36); }
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green { box-shadow: 0 12px 28px rgba(0,0,0,.28), 0 0 20px var(--green-halo), inset 0 1px 0 rgba(218,255,236,.4); }
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { box-shadow: 0 12px 28px rgba(0,0,0,.28), 0 0 20px var(--orange-halo), inset 0 1px 0 rgba(255,237,216,.4); }
/* Natural highlights: state colors tint the glass, but do not paint opaque
neon blocks over the photographic background. Heater ON is a healthy green
state too; its orange icon still communicates heat. */
.speed-card::after, .temperature-card::after { opacity: .18; filter: blur(42px); }
.power-card::after, .heater-card::after { background: var(--green-halo); opacity: .16; filter: blur(44px); }
.hero-step.primary { box-shadow: 0 9px 20px rgba(15,92,183,.22), 0 0 9px rgba(48,151,239,.14), inset 0 1px 0 rgba(255,255,255,.78), inset 0 -2px 5px rgba(0,67,156,.18); }
.hero-step:hover { box-shadow: 0 11px 23px rgba(20,101,190,.24), 0 0 12px rgba(48,151,239,.16), inset 0 1px 0 rgba(255,255,255,.76); }
.speed-segments button.active { box-shadow: 0 0 6px rgba(25,143,247,.24), inset 0 1px 0 rgba(255,255,255,.68), inset 0 -2px 4px rgba(0,84,182,.16); }
.glass-switch[aria-pressed="true"].green,
.glass-switch[aria-pressed="true"].orange {
background: linear-gradient(145deg, rgba(139,244,190,.72), rgba(55,194,122,.48));
border-color: rgba(218,255,235,.84);
box-shadow: 0 10px 23px rgba(18,143,80,.18), 0 0 11px rgba(38,199,116,.13), inset 0 1px 0 rgba(255,255,255,.82), inset 0 -3px 8px rgba(0,114,57,.1);
}
:root[data-theme="dark"] .speed-card::after,
:root[data-theme="dark"] .temperature-card::after { opacity: .2; }
:root[data-theme="dark"] .power-card::after,
:root[data-theme="dark"] .heater-card::after { opacity: .18; }
:root[data-theme="dark"] .hero-step.primary { box-shadow: 0 10px 24px rgba(0,53,119,.38), 0 0 11px rgba(41,162,244,.19), inset 0 1px 0 rgba(220,248,255,.4), inset 0 -3px 7px rgba(0,27,91,.27); }
:root[data-theme="dark"] .speed-segments button.active { box-shadow: 0 0 7px rgba(24,156,255,.27), inset 0 1px 0 rgba(223,248,255,.34); }
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green,
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange {
color: #effff6;
border-color: rgba(127,242,182,.4);
background: linear-gradient(145deg, rgba(51,190,119,.58), rgba(9,102,59,.55));
box-shadow: 0 11px 25px rgba(0,0,0,.25), 0 0 12px rgba(37,207,121,.15), inset 0 1px 0 rgba(218,255,236,.34);
}
/* Final material and icon polish. */
.ui-icon {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.feature-icon { padding: .08rem; filter: drop-shadow(0 0 6px rgba(20,139,244,.2)); text-shadow: none; }
.feature-icon.green { filter: drop-shadow(0 0 6px rgba(20,190,104,.24)); }
.feature-icon.orange { filter: drop-shadow(0 0 6px rgba(244,105,31,.22)); }
.fan-icon { font-size: inherit; }
.section-icon { padding: .06rem; filter: drop-shadow(0 0 5px rgba(26,124,210,.17)); text-shadow: none; }
.section-icon.leaf { filter: drop-shadow(0 0 5px rgba(8,160,81,.2)); }
.metric-icon { width: 1.7rem; height: 1.7rem; flex: 0 0 auto; filter: drop-shadow(0 0 4px rgba(20,124,221,.15)); text-shadow: none; }
.metric-icon.particles { padding: .05rem; }
.action-symbol { width: 2rem; height: 2rem; font-size: inherit; }
.extra-icon { width: 3.4rem; height: 3.4rem; flex: 0 0 auto; filter: drop-shadow(0 0 7px rgba(33,126,211,.17)); text-shadow: none; }
.extra-icon.wind { width: 3.8rem; height: 3.8rem; font-size: inherit; transform: none; }
:root[data-theme="light"] {
--tile: rgba(104, 126, 139, 0.16);
--tile-strong: rgba(214, 226, 232, 0.2);
--tile-line-soft: rgba(189, 211, 221, 0.38);
}
:root[data-theme="light"] .glass-tile,
:root[data-theme="light"] .glass-section {
background:
linear-gradient(132deg, rgba(244,248,250,.22), rgba(159,178,188,.055) 38%, transparent 66%),
linear-gradient(145deg, var(--tile-strong), var(--tile));
backdrop-filter: blur(14px) saturate(116%);
-webkit-backdrop-filter: blur(14px) saturate(116%);
}
.metric-card:not(.co2-card) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.metric-card:not(.co2-card) .metric-heading { width: 100%; justify-content: center; text-align: left; }
.metric-card:not(.co2-card) > strong { margin-top: .7rem; }
.metric-card:not(.co2-card) .quality-chip { align-self: center; }
.filter-card .filter-line { width: 100%; }
.hero-step.primary {
background:
radial-gradient(circle at 24% 5%, rgba(255,255,255,.55), transparent 38%),
linear-gradient(135deg, rgba(255,255,255,.16), transparent 42%, rgba(0,73,170,.1)),
linear-gradient(145deg, #55b4ff, #1479e8 72%);
box-shadow: 0 8px 18px rgba(15,92,183,.18), 0 0 7px rgba(48,151,239,.1), inset 0 1px 0 rgba(255,255,255,.86), inset 0 -3px 7px rgba(0,67,156,.16);
}
.hero-step.primary:hover { box-shadow: 0 10px 21px rgba(20,101,190,.21), 0 0 9px rgba(48,151,239,.12), inset 0 1px 0 rgba(255,255,255,.88); }
.speed-segments button.active {
background:
linear-gradient(180deg, rgba(255,255,255,.32), transparent 48%),
linear-gradient(90deg, #1685ec, #35afff);
box-shadow: 0 0 5px rgba(25,143,247,.19), inset 0 1px 0 rgba(255,255,255,.75), inset 0 -2px 4px rgba(0,84,182,.13);
}
.glass-switch { overflow: hidden; }
.glass-switch[aria-pressed="true"].green,
.glass-switch[aria-pressed="true"].orange {
background:
radial-gradient(circle at 25% -8%, rgba(255,255,255,.67), transparent 42%),
linear-gradient(132deg, rgba(255,255,255,.15), transparent 40%, rgba(0,125,62,.06)),
linear-gradient(145deg, rgba(139,244,190,.67), rgba(55,194,122,.43));
box-shadow: 0 8px 19px rgba(18,143,80,.14), 0 0 8px rgba(38,199,116,.09), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 9px rgba(0,114,57,.08);
}
.schedule-button.auto {
background:
radial-gradient(circle at 19% -30%, rgba(255,255,255,.55), transparent 44%),
linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%),
linear-gradient(145deg, rgba(211,239,255,.52), rgba(100,179,235,.18));
}
.schedule-button.pause {
background:
radial-gradient(circle at 19% -30%, rgba(255,255,255,.58), transparent 44%),
linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%),
linear-gradient(145deg, rgba(255,231,199,.55), rgba(235,151,76,.18));
}
:root[data-theme="dark"] .hero-step.primary {
background:
radial-gradient(circle at 24% 5%, rgba(214,246,255,.32), transparent 38%),
linear-gradient(135deg, rgba(255,255,255,.08), transparent 42%, rgba(0,32,95,.17)),
linear-gradient(145deg, #33aaf4, #0869d0 74%);
}
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green,
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange {
background:
radial-gradient(circle at 25% -8%, rgba(218,255,236,.3), transparent 42%),
linear-gradient(132deg, rgba(255,255,255,.07), transparent 40%, rgba(0,45,23,.12)),
linear-gradient(145deg, rgba(51,190,119,.52), rgba(9,102,59,.49));
box-shadow: 0 9px 21px rgba(0,0,0,.22), 0 0 9px rgba(37,207,121,.1), inset 0 1px 0 rgba(218,255,236,.32), inset 0 -3px 8px rgba(0,31,16,.16);
}
:root[data-theme="dark"] .schedule-button.auto {
background:
radial-gradient(circle at 19% -30%, rgba(208,243,255,.17), transparent 45%),
linear-gradient(145deg, rgba(38,125,187,.26), rgba(15,61,91,.25));
}
:root[data-theme="dark"] .schedule-button.pause {
background:
radial-gradient(circle at 19% -30%, rgba(255,226,199,.16), transparent 45%),
linear-gradient(145deg, rgba(167,82,27,.27), rgba(73,40,25,.25));
}
@media (min-width: 1300px) and (min-height: 760px) {
.climate-panel {
width: 100%;
max-width: none;
display: grid;
grid-template-rows: auto minmax(18rem, 1.4fr) minmax(11rem, .78fr) minmax(13rem, .9fr) auto;
gap: .75rem;
}
.panel-header { padding-bottom: 0; }
.primary-controls, .bottom-grid { min-height: 0; height: 100%; }
.control-card, .switch-card { min-height: 0; height: 100%; }
.content-section { min-height: 0; height: 100%; margin-top: 0; display: flex; flex-direction: column; }
.air-grid { min-height: 0; flex: 1; }
.metric-card { min-height: 0; height: 100%; }
.schedule-layout, .extras-grid { min-height: 0; flex: 1; }
.schedule-times, .extra-card { min-height: 0; height: 100%; }
}
@media (min-width: 1700px) {
html { font-size: 18px; }
.climate-panel { padding: 1.2rem; }
}
@media (max-width: 1120px) {
.primary-controls { grid-template-columns: 1fr 1fr; }
.switch-card { min-height: 9rem; }
.glass-switch { min-height: 3.8rem; }
.air-grid { grid-template-columns: repeat(3, 1fr); }
.bottom-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
body { padding: .5rem; }
.climate-panel { border-radius: 1.1rem; }
.panel-header, .identity, .header-tools { flex-wrap: wrap; }
.panel-header { align-items: flex-start; }
.header-tools { width: 100%; justify-content: flex-start; }
.ventilation-badge { min-width: 0; flex: 1; }
.primary-controls { grid-template-columns: 1fr; }
.control-card, .switch-card { min-height: auto; }
.hero-control { margin: 1.4rem 0 .75rem; }
.air-grid { grid-template-columns: 1fr 1fr; }
.co2-card, .filter-card { grid-column: 1 / -1; }
.schedule-layout { grid-template-columns: 1fr; }
}
@media (max-width: 480px) {
.identity h1 { width: 100%; }
.ventilation-badge small { display: none; }
.air-grid, .extras-grid { grid-template-columns: 1fr; }
.co2-card, .filter-card { grid-column: auto; }
.schedule-times { grid-template-columns: 1fr; }
.schedule-times > i { width: 100%; height: 1px; }
.micro-settings { flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) { .hero-step:hover { transform: none; } }
/* Schedule controls and editor. */
.schedule-title-tools { margin-left: auto; }
.schedule-edit-button {
display: inline-flex;
align-items: center;
gap: .4rem;
min-height: 2.05rem;
padding: .36rem .68rem;
border: 1px solid var(--tile-line);
border-radius: .7rem;
color: #126fc4;
background:
radial-gradient(circle at 20% -35%, rgba(255,255,255,.48), transparent 48%),
linear-gradient(145deg, rgba(220,241,253,.42), rgba(100,173,220,.13));
box-shadow: 0 6px 15px rgba(27,91,135,.1), inset 0 1px 0 rgba(255,255,255,.76);
cursor: pointer;
font-size: .65rem;
font-weight: 750;
transition: transform .15s ease, filter .15s ease;
}
.schedule-edit-button svg { width: 1rem; height: 1rem; }
.schedule-edit-button:hover { transform: translateY(-1px); filter: brightness(1.06); }
.schedule-button:disabled { cursor: not-allowed; opacity: .5; filter: saturate(.38); box-shadow: none; }
.schedule-button:disabled:hover { transform: none; filter: saturate(.38); }
.schedule-button[aria-busy="true"] { cursor: wait; }
#schedule-state.warn {
color: #8d3b00;
border-color: rgba(247,162,73,.78);
background: linear-gradient(145deg, rgba(255,232,190,.82), rgba(240,157,69,.42));
box-shadow: 0 0 12px rgba(237,130,39,.13), inset 0 1px 0 rgba(255,255,255,.86);
font-size: .66rem;
letter-spacing: .07em;
}
:root[data-theme="dark"] #schedule-state.warn {
color: #ffe2b8;
border-color: rgba(255,166,83,.57);
background: linear-gradient(145deg, rgba(177,87,24,.52), rgba(95,45,17,.48));
box-shadow: 0 0 13px rgba(242,129,38,.15), inset 0 1px 0 rgba(255,228,198,.18);
}
.schedule-dialog {
width: min(72rem, calc(100vw - 2rem));
max-width: none;
max-height: calc(100vh - 2rem);
padding: 0;
border: 1px solid var(--panel-edge);
border-radius: 1.35rem;
color: var(--panel-ink);
background:
linear-gradient(125deg, rgba(255,255,255,.28), transparent 32%, rgba(255,255,255,.06) 72%),
rgba(201,229,244,.72);
box-shadow: 0 28px 90px rgba(8,45,70,.42), inset 0 1px 0 rgba(255,255,255,.9);
backdrop-filter: blur(28px) saturate(135%);
-webkit-backdrop-filter: blur(28px) saturate(135%);
overflow: hidden;
}
.schedule-dialog::backdrop { background: rgba(5,20,31,.46); backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px); }
:root[data-theme="dark"] .schedule-dialog { background: linear-gradient(125deg, rgba(205,239,255,.1), transparent 34%), rgba(7,24,36,.88); box-shadow: 0 30px 100px rgba(0,0,0,.64), inset 0 1px 0 rgba(221,243,252,.15); }
.schedule-editor { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: calc(100vh - 2rem); }
.editor-header, .editor-footer { display: flex; align-items: center; gap: .7rem; padding: .8rem 1rem; }
.editor-header { justify-content: space-between; border-bottom: 1px solid var(--tile-line-soft); }
.editor-heading { display: flex; align-items: center; gap: .65rem; }
.editor-heading > svg { width: 2rem; height: 2rem; color: #1883df; filter: drop-shadow(0 0 5px rgba(24,131,223,.2)); }
.editor-heading h2 { margin: 0; font-size: 1.15rem; }
.editor-heading small { display: block; margin-top: .15rem; color: var(--panel-muted); font-size: .63rem; }
.editor-close { width: 2.2rem; height: 2.2rem; padding: 0; border: 1px solid var(--tile-line); border-radius: .75rem; color: var(--panel-muted); background: var(--tile); cursor: pointer; font-size: 1.45rem; line-height: 1; }
.editor-scroll { display: grid; gap: .7rem; padding: .85rem 1rem 1rem; overflow: auto; }
.schedule-enabled { display: flex; align-items: center; gap: .7rem; padding: .72rem .82rem; border: 1px solid var(--tile-line); border-radius: .9rem; background: linear-gradient(145deg, rgba(255,255,255,.22), rgba(120,169,195,.08)); cursor: pointer; }
.schedule-enabled input { width: 1.1rem; height: 1.1rem; accent-color: var(--blue); }
.schedule-enabled span { display: grid; gap: .12rem; }
.schedule-enabled strong { font-size: .76rem; }
.schedule-enabled small { color: var(--panel-muted); font-size: .61rem; }
.editor-section { padding: .8rem; border: 1px solid var(--tile-line); border-radius: 1rem; background: linear-gradient(145deg, rgba(255,255,255,.19), rgba(94,148,179,.075)); box-shadow: inset 0 1px 0 rgba(255,255,255,.48); }
:root[data-theme="dark"] .editor-section, :root[data-theme="dark"] .schedule-enabled { background: linear-gradient(145deg, rgba(205,236,249,.055), rgba(2,19,30,.12)); box-shadow: inset 0 1px 0 rgba(224,246,255,.07); }
.editor-section-title { display: flex; align-items: end; justify-content: space-between; gap: .75rem; margin-bottom: .65rem; }
.editor-section-title h3 { margin: 0; font-size: .86rem; }
.editor-section-title small { display: block; margin-top: .13rem; color: var(--panel-muted); font-size: .6rem; }
.day-assignments { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: .42rem; }
.day-assignment { display: grid; gap: .3rem; color: var(--panel-muted); font-size: .58rem; font-weight: 700; }
.day-assignment select, .template-toolbar select, .point-editor input, .point-editor select {
min-width: 0;
height: 2.25rem;
padding: .38rem .48rem;
border: 1px solid var(--tile-line);
border-radius: .62rem;
color: var(--panel-ink);
background: rgba(245,251,253,.48);
outline: none;
font-size: .66rem;
}
:root[data-theme="dark"] .day-assignment select,
:root[data-theme="dark"] .template-toolbar select,
:root[data-theme="dark"] .point-editor input,
:root[data-theme="dark"] .point-editor select { background: rgba(16,38,52,.72); }
.day-assignment select:focus, .template-toolbar select:focus, .point-editor input:focus, .point-editor select:focus { border-color: rgba(45,151,230,.7); box-shadow: 0 0 0 3px rgba(34,145,226,.11); }
.template-toolbar > label { display: grid; gap: .25rem; min-width: 10rem; color: var(--panel-muted); font-size: .57rem; font-weight: 700; }
.template-actions { display: flex; align-items: end; gap: .35rem; }
.template-actions button { min-height: 2.25rem; padding: .4rem .65rem; border: 1px solid var(--tile-line); border-radius: .62rem; color: #126fc4; background: rgba(52,150,220,.08); cursor: pointer; font-size: .61rem; font-weight: 700; }
.template-actions #delete-template { color: var(--red); border-color: rgba(223,91,105,.28); background: var(--red-soft); }
.template-actions button:disabled { cursor: not-allowed; opacity: .45; }
.schedule-points { display: grid; gap: .55rem; }
.point-editor { padding: .68rem; border: 1px solid var(--tile-line); border-radius: .85rem; background: linear-gradient(145deg, rgba(255,255,255,.22), rgba(116,160,183,.065)); box-shadow: inset 0 1px 0 rgba(255,255,255,.45); }
:root[data-theme="dark"] .point-editor { background: linear-gradient(145deg, rgba(197,231,247,.055), rgba(2,18,29,.13)); box-shadow: inset 0 1px 0 rgba(224,246,255,.06); }
.point-header { display: grid; grid-template-columns: auto minmax(7rem, .7fr) minmax(9rem, 1fr) auto; align-items: end; gap: .55rem; }
.point-number { align-self: center; display: grid; place-items: center; width: 1.85rem; height: 1.85rem; border-radius: 50%; color: #1478cc; background: var(--blue-soft); font-size: .62rem; font-weight: 800; }
.point-header label, .point-setting { display: grid; gap: .25rem; color: var(--panel-muted); font-size: .56rem; font-weight: 700; }
.remove-point { width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(223,91,105,.32); border-radius: .62rem; color: var(--red); background: var(--red-soft); cursor: pointer; font-size: 1.1rem; }
.point-settings { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: .48rem; margin-top: .55rem; }
.point-editor.auto [data-set-only] { display: none; }
.point-editor.auto .point-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.empty-points { padding: 1.4rem; border: 1px dashed var(--tile-line); border-radius: .8rem; color: var(--panel-muted); text-align: center; font-size: .68rem; }
.add-schedule-point { width: 100%; min-height: 2.55rem; margin-top: .6rem; border: 1px dashed rgba(37,131,205,.55); border-radius: .75rem; color: #126fc4; background: rgba(52,150,220,.08); cursor: pointer; font-size: .68rem; font-weight: 750; }
.editor-footer { border-top: 1px solid var(--tile-line-soft); justify-content: flex-end; }
.editor-status { margin-right: auto; color: var(--panel-muted); font-size: .63rem; }
.editor-status.error { color: var(--red); }
.editor-button { min-height: 2.5rem; padding: .58rem .9rem; border-radius: .75rem; cursor: pointer; font-size: .69rem; font-weight: 750; }
.editor-button.secondary { border: 1px solid var(--tile-line); color: var(--panel-ink); background: var(--tile); }
.editor-button.primary { border: 1px solid rgba(182,226,255,.76); color: white; background: radial-gradient(circle at 20% -30%, rgba(255,255,255,.5), transparent 48%), linear-gradient(145deg, #3aa9eb, #1476c9); box-shadow: 0 7px 17px rgba(18,102,170,.2), inset 0 1px 0 rgba(255,255,255,.75); }
@media (max-width: 900px) {
.day-assignments { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.point-settings { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
.schedule-dialog { width: calc(100vw - .7rem); max-height: calc(100vh - .7rem); border-radius: 1rem; }
.schedule-editor { max-height: calc(100vh - .7rem); }
.editor-header, .editor-footer { padding: .7rem; }
.editor-scroll { padding: .7rem; }
.day-assignments { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.editor-section-title { align-items: stretch; flex-direction: column; }
.template-toolbar > label { min-width: 0; }
.template-actions { align-items: stretch; }
.template-actions button { flex: 1; }
.point-header { grid-template-columns: auto 1fr auto; }
.point-header .point-action { grid-column: 2 / -1; }
.point-settings, .point-editor.auto .point-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.editor-footer { flex-wrap: wrap; }
.editor-status { width: 100%; }
.schedule-edit-button span { display: none; }
}
/* CO2 state drives both the label and the meter, so the visual state cannot
disagree with the air-quality text returned by airQuality(). */
.co2-card {
--air-color: #7896ac;
--air-glow: rgba(93, 140, 171, .22);
--air-track: rgba(181, 203, 200, .28);
}
.co2-card[data-air-tone="good"] {
--air-color: #1fbd69;
--air-glow: rgba(18, 199, 104, .34);
}
.co2-card[data-air-tone="warn"] {
--air-color: #efa12b;
--air-glow: rgba(244, 161, 42, .35);
}
.co2-card[data-air-tone="bad"] {
--air-color: #e8495c;
--air-glow: rgba(235, 74, 83, .36);
}
.co2-card .co2-meter {
height: .84rem;
border-color: rgba(255,255,255,.82);
background-color: var(--air-track);
background-image: linear-gradient(180deg, rgba(255,255,255,.15), transparent 52%, rgba(0,20,36,.045));
box-shadow: inset 0 1px 3px rgba(20,61,78,.14), 0 1px 4px rgba(8,43,72,.07);
transition: background-color .65s ease;
}
.co2-card .co2-meter i {
background-color: var(--air-color);
background-image: linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,.045) 48%, rgba(0,35,20,.1));
box-shadow: 0 0 8px var(--air-glow), inset 0 1px 0 rgba(255,255,255,.58), inset 0 -1px 2px rgba(0,34,19,.1);
transition: width .35s ease, background-color .65s ease, box-shadow .65s ease;
}
:root[data-theme="dark"] .co2-card .co2-meter { border-color: rgba(205,235,248,.3); box-shadow: inset 0 1px 4px rgba(0,0,0,.27), 0 1px 5px rgba(0,0,0,.12); }
.co2-card .quality-chip.muted {
color: var(--panel-muted);
border-color: var(--tile-line-soft);
background: rgba(199,222,235,.2);
box-shadow: inset 0 1px 0 rgba(255,255,255,.55);
}
.co2-card .quality-chip.good {
color: #076d3d;
border-color: rgba(109,237,167,.65);
background: linear-gradient(145deg, rgba(179,255,214,.58), rgba(92,218,151,.39));
box-shadow: 0 0 9px rgba(33,203,116,.12), inset 0 1px 0 rgba(255,255,255,.82);
}
.co2-card .quality-chip.warn {
color: #9c5513;
border-color: rgba(244,183,106,.67);
background: linear-gradient(145deg, rgba(255,232,188,.56), rgba(239,168,80,.34));
box-shadow: 0 0 9px rgba(235,153,53,.11), inset 0 1px 0 rgba(255,255,255,.8);
}
.co2-card .quality-chip.bad {
color: #a62f3d;
border-color: rgba(242,135,147,.62);
background: linear-gradient(145deg, rgba(255,209,216,.53), rgba(225,105,120,.3));
box-shadow: 0 0 9px rgba(224,83,101,.12), inset 0 1px 0 rgba(255,255,255,.78);
}
:root[data-theme="dark"] .co2-card { --air-track: rgba(8,28,39,.38); }
:root[data-theme="dark"] .co2-card[data-air-tone="good"] { --air-glow: rgba(30,224,128,.3); }
:root[data-theme="dark"] .co2-card[data-air-tone="warn"] { --air-glow: rgba(255,176,49,.3); }
:root[data-theme="dark"] .co2-card[data-air-tone="bad"] { --air-glow: rgba(255,82,102,.32); }
:root[data-theme="dark"] .co2-card .quality-chip.good { color: #b9ffda; background: linear-gradient(145deg, rgba(45,186,113,.35), rgba(11,101,60,.3)); }
:root[data-theme="dark"] .co2-card .quality-chip.warn { color: #ffd59e; background: linear-gradient(145deg, rgba(176,115,30,.34), rgba(91,58,15,.3)); }
:root[data-theme="dark"] .co2-card .quality-chip.bad { color: #ffc0c8; background: linear-gradient(145deg, rgba(174,58,75,.34), rgba(91,25,37,.31)); }
:root[data-theme="dark"] .co2-card .quality-chip.muted { color: var(--panel-muted); background: rgba(42,70,88,.25); }
+601
View File
@@ -0,0 +1,601 @@
:root {
--widget-ink: #08265d;
--widget-muted: #315d91;
--widget-glass: rgba(200, 228, 244, 0.21);
--widget-edge: rgba(255, 255, 255, 0.9);
--tile: rgba(104, 126, 139, 0.16);
--tile-strong: rgba(214, 226, 232, 0.2);
--tile-edge: rgba(255, 255, 255, 0.82);
--tile-edge-soft: rgba(189, 211, 221, 0.38);
--tile-shadow: 0 10px 25px rgba(24, 72, 105, 0.12), inset 0 1px 0 rgba(255,255,255,.78), inset 1px 0 0 rgba(255,255,255,.24);
--blue-halo: rgba(23, 137, 248, 0.16);
--green-halo: rgba(28, 196, 113, 0.16);
--orange-halo: rgba(246, 126, 50, 0.14);
}
:root[data-theme="dark"] {
--widget-ink: #edf8ff;
--widget-muted: #9fc5df;
--widget-glass: rgba(7, 22, 33, 0.49);
--widget-edge: rgba(187, 225, 244, 0.3);
--tile: rgba(16, 37, 51, 0.3);
--tile-strong: rgba(52, 78, 96, 0.33);
--tile-edge: rgba(203, 234, 248, 0.25);
--tile-edge-soft: rgba(114, 176, 207, 0.16);
--tile-shadow: 0 13px 29px rgba(0,0,0,.26), inset 0 1px 0 rgba(226,246,255,.11), inset 1px 0 0 rgba(186,226,245,.055);
--blue-halo: rgba(29, 153, 245, 0.22);
--green-halo: rgba(27, 210, 121, 0.19);
--orange-halo: rgba(246, 125, 46, 0.18);
}
html { min-width: 280px; }
body {
min-height: 100vh;
margin: 0;
padding: clamp(.5rem, 2.2vw, 1rem);
display: flex;
align-items: flex-start;
justify-content: center;
color: var(--widget-ink);
background-color: #b7d5e3;
overflow-x: hidden;
isolation: isolate;
}
:root[data-theme="dark"] body {
background-color: #091722;
}
body::before,
body::after {
content: "";
position: fixed;
pointer-events: none;
}
body::before {
inset: -1.25rem;
z-index: -2;
background-image:
linear-gradient(118deg, rgba(180,220,239,.15), rgba(238,247,251,.025) 45%, rgba(247,226,204,.09)),
url("../assets/mountain-lake-light.jpg");
background-position: center;
background-size: cover;
background-repeat: no-repeat;
filter: blur(6px) saturate(108%);
transform: scale(1.025);
}
body::after {
inset: 0;
z-index: -1;
background:
radial-gradient(circle at 11% 18%, rgba(255,255,255,.32) 0 .5%, transparent 5%),
radial-gradient(circle at 64% 23%, rgba(255,244,218,.27) 0 .6%, transparent 6%),
radial-gradient(circle at 91% 79%, rgba(225,250,237,.21) 0 .8%, transparent 7%);
filter: blur(11px);
opacity: .5;
}
:root[data-theme="dark"] body::before {
background-image:
linear-gradient(118deg, rgba(2,15,25,.66), rgba(8,27,40,.57) 48%, rgba(20,25,29,.64)),
url("../assets/mountain-lake-dark.jpg");
filter: blur(7px) saturate(106%);
}
:root[data-theme="dark"] body::after { opacity: .25; }
.ambient::before { width: 32rem; height: 32rem; left: -13rem; top: -14rem; background: #c9f2ff; opacity: .14; filter: blur(76px); }
.ambient::after { width: 34rem; height: 34rem; right: -14rem; bottom: -16rem; background: #ebd5a7; opacity: .11; filter: blur(82px); }
:root[data-theme="dark"] .ambient::before { background: #147db5; opacity: .12; }
:root[data-theme="dark"] .ambient::after { background: #846630; opacity: .08; }
.widget {
position: relative;
isolation: isolate;
width: min(100%, 31.5rem);
display: grid;
gap: .65rem;
padding: clamp(.65rem, 2.2vw, .9rem);
overflow: hidden;
border: 1px solid var(--widget-edge);
outline: 1px solid rgba(255,255,255,.48);
outline-offset: -5px;
border-radius: 1.55rem;
background:
linear-gradient(125deg, rgba(255,255,255,.22), transparent 31%, rgba(255,255,255,.055) 69%, rgba(196,224,240,.1)),
var(--widget-glass);
box-shadow: 0 27px 68px rgba(22,66,95,.24), 0 2px 8px rgba(255,255,255,.28), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -1px 0 rgba(113,163,192,.17);
backdrop-filter: blur(13px) saturate(124%);
-webkit-backdrop-filter: blur(13px) saturate(124%);
}
.widget::before,
.widget::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
pointer-events: none;
}
.widget::before {
background:
linear-gradient(112deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 18%, transparent 39%),
radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.25), transparent 47%);
}
.widget::after {
opacity: .1;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
mix-blend-mode: soft-light;
}
.widget > * { position: relative; z-index: 1; }
:root[data-theme="dark"] .widget { outline-color: rgba(203,235,250,.11); box-shadow: 0 33px 78px rgba(0,0,0,.43), 0 0 28px rgba(19,112,158,.06), inset 0 1px 0 rgba(221,243,252,.17), inset 0 -1px 0 rgba(0,0,0,.28); }
:root[data-theme="dark"] .widget::before { background: linear-gradient(112deg, rgba(203,238,253,.1), transparent 24%, transparent 72%, rgba(71,163,202,.035)); }
.widget-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: .55rem .7rem;
padding: .15rem .25rem .05rem;
}
.identity { min-width: 0; display: flex; align-items: center; gap: .7rem; }
.brand-icon {
display: grid;
place-items: center;
width: 2.9rem;
height: 2.9rem;
flex: 0 0 auto;
border: 1px solid rgba(255,255,255,.82);
border-radius: 1rem;
color: #147bd5;
background:
radial-gradient(circle at 25% 2%, rgba(255,255,255,.62), transparent 43%),
linear-gradient(145deg, rgba(217,242,255,.58), rgba(73,159,222,.2));
box-shadow: 0 8px 19px rgba(19,101,160,.13), inset 0 1px 0 rgba(255,255,255,.88);
}
.brand-icon svg { width: 1.75rem; height: 1.75rem; filter: drop-shadow(0 0 5px rgba(20,123,213,.18)); }
.identity-copy { min-width: 0; display: block; }
.title-row { display: flex; align-items: center; gap: .55rem; min-width: 0; }
.identity h1 { margin: 0; font-size: 1.62rem; line-height: 1; letter-spacing: -.045em; white-space: nowrap; }
.identity-copy > small { display: block; margin-top: .28rem; color: var(--widget-muted); font-size: .7rem; }
.widget .pill { min-height: 1.75rem; padding: .27rem .58rem; border-color: rgba(255,255,255,.56); font-size: .59rem; box-shadow: inset 0 1px 0 rgba(255,255,255,.62), 0 5px 14px rgba(26,75,108,.08); backdrop-filter: blur(12px); }
.widget .pill::before { width: .36rem; height: .36rem; box-shadow: 0 0 7px currentColor; }
.theme-button,
.open-section {
display: grid;
place-items: center;
border: 1px solid var(--tile-edge);
color: var(--widget-ink);
background: linear-gradient(145deg, var(--tile-strong), var(--tile));
box-shadow: var(--tile-shadow);
cursor: pointer;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
transition: transform .15s ease, filter .15s ease;
}
.theme-button { width: 2.55rem; height: 2.55rem; border-radius: .9rem; }
.theme-button svg { width: 1.18rem; height: 1.18rem; }
.theme-button:hover, .open-section:hover { transform: translateY(-1px); filter: brightness(1.07); }
.theme-button:active, .open-section:active { transform: scale(.97); }
.status-row { grid-column: 1 / -1; display: flex; align-items: center; gap: .55rem; min-width: 0; }
.mode-detail { min-width: 0; color: var(--widget-muted); font-size: .66rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.widget-tile {
position: relative;
isolation: isolate;
overflow: hidden;
border: 1px solid var(--tile-edge);
outline: 1px solid var(--tile-edge-soft);
outline-offset: -3px;
border-radius: 1.15rem;
background:
linear-gradient(132deg, rgba(244,248,250,.22), rgba(159,178,188,.055) 38%, transparent 66%),
linear-gradient(145deg, var(--tile-strong), var(--tile));
box-shadow: var(--tile-shadow);
backdrop-filter: blur(14px) saturate(116%);
-webkit-backdrop-filter: blur(14px) saturate(116%);
}
.widget-tile::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
pointer-events: none;
opacity: .62;
background: linear-gradient(115deg, rgba(255,255,255,.35), transparent 25%, transparent 72%, rgba(255,255,255,.08));
}
.widget-tile > * { position: relative; z-index: 1; }
:root[data-theme="dark"] .widget-tile { background: linear-gradient(132deg, rgba(213,240,252,.075), rgba(255,255,255,.012) 38%, transparent 65%), linear-gradient(145deg, var(--tile-strong), var(--tile)); backdrop-filter: blur(16px) saturate(127%); -webkit-backdrop-filter: blur(16px) saturate(127%); }
.control-card { min-height: 11.4rem; padding: .9rem 1rem .85rem; display: flex; flex-direction: column; }
.control-card::after,
.switch-card::after {
content: "";
position: absolute;
z-index: 0;
width: 12rem;
height: 12rem;
right: -5rem;
bottom: -7rem;
border-radius: 50%;
pointer-events: none;
filter: blur(42px);
opacity: .18;
}
.speed-card::after { background: var(--blue-halo); }
.temperature-card::after { width: 17rem; background: linear-gradient(90deg, var(--blue-halo), var(--orange-halo)); }
.power-card::after, .heater-card::after { background: var(--green-halo); opacity: .16; }
.card-title { display: flex; align-items: flex-start; gap: .65rem; }
.card-title h2, .switch-title h2, .section-title h2 { margin: 0; font-size: .96rem; line-height: 1.2; letter-spacing: -.015em; }
.card-title p { margin: .16rem 0 0; color: var(--widget-muted); font-size: .66rem; }
.ui-icon { display: block; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.feature-icon { width: 1.95rem; height: 1.95rem; flex: 0 0 auto; padding: .06rem; color: #1389f4; filter: drop-shadow(0 0 5px rgba(20,139,244,.2)); }
.hero-control { display: grid; grid-template-columns: 3.45rem 1fr 3.45rem; align-items: center; gap: .8rem; margin: 1rem 0 .72rem; }
.hero-value { text-align: center; font-size: 3rem; line-height: 1; letter-spacing: -.065em; font-variant-numeric: tabular-nums; }
.hero-step {
display: grid;
place-items: center;
width: 3.45rem;
height: 3.45rem;
padding: 0;
border: 1.5px solid rgba(255,255,255,.84);
border-radius: .95rem;
color: #176dbb;
background:
radial-gradient(circle at 24% 5%, rgba(255,255,255,.48), transparent 40%),
linear-gradient(145deg, rgba(245,252,255,.4), rgba(191,224,244,.18));
box-shadow: 0 8px 19px rgba(35,92,132,.13), inset 0 1px 0 rgba(255,255,255,.88), 0 0 0 1px rgba(185,226,248,.2);
cursor: pointer;
font-size: 1.8rem;
line-height: 1;
transition: transform .15s ease, filter .15s ease, box-shadow .15s ease;
}
.hero-step.primary {
color: white;
background:
radial-gradient(circle at 24% 5%, rgba(255,255,255,.55), transparent 38%),
linear-gradient(135deg, rgba(255,255,255,.16), transparent 42%, rgba(0,73,170,.1)),
linear-gradient(145deg, #55b4ff, #1479e8 72%);
box-shadow: 0 8px 18px rgba(15,92,183,.18), 0 0 7px rgba(48,151,239,.1), inset 0 1px 0 rgba(255,255,255,.86), inset 0 -3px 7px rgba(0,67,156,.16);
}
.hero-step:hover { transform: translateY(-1px); filter: brightness(1.07); box-shadow: 0 10px 21px rgba(20,101,190,.2), 0 0 9px rgba(48,151,239,.12), inset 0 1px 0 rgba(255,255,255,.88); }
.hero-step:active { transform: scale(.97); }
.speed-segments { display: grid; grid-template-columns: repeat(6, 1fr); gap: .18rem; }
.speed-segments button {
height: .9rem;
padding: 0;
border: 1px solid rgba(255,255,255,.82);
border-radius: .5rem;
background: linear-gradient(180deg, rgba(173,207,229,.3), rgba(98,151,190,.18));
box-shadow: inset 0 1px 2px rgba(27,70,105,.07), 0 2px 5px rgba(32,86,126,.07);
cursor: pointer;
transition: background .12s ease, box-shadow .12s ease, transform .12s ease;
}
.speed-segments button:hover { transform: translateY(-1px); }
.speed-segments button.active {
background: linear-gradient(180deg, rgba(255,255,255,.32), transparent 48%), linear-gradient(90deg, #1685ec, #35afff);
box-shadow: 0 0 5px rgba(25,143,247,.19), inset 0 1px 0 rgba(255,255,255,.75), inset 0 -2px 4px rgba(0,84,182,.13);
}
.scale-labels, .temperature-labels { display: flex; justify-content: space-between; margin-top: .34rem; padding: 0 .3rem; color: var(--widget-muted); font-size: .62rem; }
.temperature-range { width: 100%; height: 1.45rem; margin: 0; appearance: none; background: transparent; cursor: pointer; }
.temperature-range::-webkit-slider-runnable-track { height: .68rem; border: 1px solid rgba(255,255,255,.88); border-radius: 999px; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); box-shadow: 0 0 8px rgba(37,153,239,.15), 0 0 7px rgba(255,114,41,.09), inset 0 1px 2px rgba(255,255,255,.46); }
.temperature-range::-moz-range-track { height: .68rem; border: 1px solid rgba(255,255,255,.88); border-radius: 999px; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); }
.temperature-range::-moz-range-progress { background: transparent; }
.temperature-range::-webkit-slider-thumb { width: 1.6rem; height: 1.6rem; margin-top: -.49rem; appearance: none; border: .25rem solid rgba(244,252,255,.9); border-radius: 50%; background: #238feb; box-shadow: 0 0 0 2px rgba(255,255,255,.45), 0 0 11px rgba(29,139,241,.31), 0 4px 9px rgba(17,79,143,.22); }
.temperature-range::-moz-range-thumb { width: 1.08rem; height: 1.08rem; border: .25rem solid rgba(244,252,255,.9); border-radius: 50%; background: #238feb; box-shadow: 0 0 0 2px rgba(255,255,255,.45), 0 0 11px rgba(29,139,241,.31), 0 4px 9px rgba(17,79,143,.22); }
.switch-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .65rem; }
.switch-card { min-width: 0; min-height: 8.15rem; padding: .82rem; display: flex; flex-direction: column; gap: .72rem; }
.switch-title { display: flex; align-items: center; gap: .5rem; min-width: 0; }
.switch-title > span { min-width: 0; }
.switch-title small { display: block; margin-top: .12rem; color: var(--widget-muted); font-size: .58rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.switch-icon { width: 1.75rem; height: 1.75rem; flex: 0 0 auto; }
.switch-icon.green { color: #08a95a; filter: drop-shadow(0 0 5px rgba(20,190,104,.23)); }
.switch-icon.orange { color: #f06419; filter: drop-shadow(0 0 5px rgba(244,105,31,.2)); }
.glass-switch {
position: relative;
overflow: hidden;
width: 100%;
min-height: 3.35rem;
margin-top: auto;
border: 1.5px solid rgba(255,255,255,.86);
border-radius: .95rem;
color: var(--widget-ink);
background: rgba(199,222,235,.24);
box-shadow: var(--tile-shadow);
cursor: pointer;
font-size: 1.25rem;
font-weight: 850;
text-shadow: 0 1px 0 rgba(255,255,255,.3);
transition: transform .15s ease, background .2s ease, box-shadow .2s ease, filter .2s ease;
}
.glass-switch:hover { transform: translateY(-1px); filter: brightness(1.06); }
.glass-switch:active { transform: scale(.98); }
.glass-switch[aria-pressed="true"].green,
.glass-switch[aria-pressed="true"].orange {
background:
radial-gradient(circle at 25% -8%, rgba(255,255,255,.67), transparent 42%),
linear-gradient(132deg, rgba(255,255,255,.15), transparent 40%, rgba(0,125,62,.06)),
linear-gradient(145deg, rgba(139,244,190,.67), rgba(55,194,122,.43));
border-color: rgba(218,255,235,.84);
box-shadow: 0 8px 19px rgba(18,143,80,.14), 0 0 8px rgba(38,199,116,.09), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 9px rgba(0,114,57,.08);
}
.info-card { padding: .82rem; }
.section-title { display: flex; align-items: center; justify-content: space-between; gap: .65rem; }
.section-title > span { display: flex; align-items: center; gap: .5rem; }
.section-icon { width: 1.75rem; height: 1.75rem; color: #207bcc; filter: drop-shadow(0 0 5px rgba(26,124,210,.17)); }
.section-icon.leaf { color: #069c51; transform: rotate(-18deg); filter: drop-shadow(0 0 5px rgba(8,160,81,.2)); }
.open-section { width: 2rem; height: 2rem; padding: 0; border-radius: .7rem; color: #147bd5; font-size: .96rem; }
.co2-summary { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .6rem; margin: .8rem .1rem .52rem; }
.co2-label { color: var(--widget-muted); font-size: .74rem; font-weight: 750; }
.co2-summary strong { text-align: center; font-size: 2rem; line-height: 1; letter-spacing: -.045em; }
.co2-summary strong small { color: var(--widget-muted); font-size: .62rem; font-weight: 650; letter-spacing: 0; }
.quality-chip { display: inline-flex; justify-content: center; padding: .32rem .62rem; border: 1px solid var(--tile-edge-soft); border-radius: 999px; background: rgba(199,222,235,.2); color: var(--widget-muted); font-size: .6rem; font-weight: 750; white-space: nowrap; }
.quality-chip.good { color: #076d3d; border-color: rgba(109,237,167,.65); background: linear-gradient(145deg, rgba(179,255,214,.58), rgba(92,218,151,.39)); box-shadow: 0 0 9px rgba(33,203,116,.12), inset 0 1px 0 rgba(255,255,255,.82); }
.quality-chip.warn { color: #9c5513; border-color: rgba(244,183,106,.67); background: linear-gradient(145deg, rgba(255,232,188,.56), rgba(239,168,80,.34)); }
.quality-chip.bad { color: #a62f3d; border-color: rgba(242,135,147,.62); background: linear-gradient(145deg, rgba(255,209,216,.53), rgba(225,105,120,.3)); }
:root[data-theme="dark"] .quality-chip.good { color: #b9ffda; }
:root[data-theme="dark"] .quality-chip.warn { color: #ffd59e; }
:root[data-theme="dark"] .quality-chip.bad { color: #ffc0c8; }
.air-card {
--air-color: #7896ac;
--air-glow: rgba(93, 140, 171, .2);
--air-track: rgba(181, 203, 200, .28);
}
.air-card[data-air-tone="good"] {
--air-color: #1fbd69;
--air-glow: rgba(18, 199, 104, .34);
}
.air-card[data-air-tone="warn"] {
--air-color: #efa12b;
--air-glow: rgba(244, 161, 42, .35);
}
.air-card[data-air-tone="bad"] {
--air-color: #e8495c;
--air-glow: rgba(235, 74, 83, .36);
}
.co2-meter { height: .72rem; border: 1px solid rgba(255,255,255,.82); border-radius: 999px; background-color: var(--air-track); background-image: linear-gradient(180deg, rgba(255,255,255,.15), transparent 52%, rgba(0,20,36,.045)); box-shadow: inset 0 1px 3px rgba(20,61,78,.14), 0 1px 4px rgba(8,43,72,.07); overflow: hidden; transition: background-color .65s ease; }
.co2-meter i { display: block; width: 0; height: 100%; border-radius: inherit; background-color: var(--air-color); background-image: linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,.045) 48%, rgba(0,35,20,.1)); box-shadow: 0 0 8px var(--air-glow), inset 0 1px 0 rgba(255,255,255,.58), inset 0 -1px 2px rgba(0,34,19,.1); transition: width .35s ease, background-color .65s ease, box-shadow .65s ease; }
.meter-labels { display: flex; justify-content: space-between; margin-top: .27rem; padding: 0 .2rem; color: rgba(22,69,108,.78); font-size: .58rem; font-weight: 650; text-shadow: 0 1px 0 rgba(255,255,255,.48); }
:root[data-theme="dark"] .meter-labels { color: rgba(211,235,248,.76); text-shadow: 0 1px 2px rgba(0,0,0,.58); }
.mini-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: .52rem; margin-top: .68rem; }
.mini-metric { min-width: 0; min-height: 4.35rem; display: flex; align-items: center; justify-content: center; gap: .5rem; padding: .62rem .5rem; border: 1px solid var(--tile-edge-soft); border-radius: .9rem; background: linear-gradient(145deg, rgba(255,255,255,.13), rgba(107,151,177,.055)); box-shadow: inset 0 1px 0 rgba(255,255,255,.33); text-align: center; }
.metric-icon { width: 1.55rem; height: 1.55rem; flex: 0 0 auto; color: #137edc; filter: drop-shadow(0 0 4px rgba(20,124,221,.15)); }
.mini-metric span { min-width: 0; }
.mini-metric small { display: block; color: var(--widget-muted); font-size: .58rem; }
.mini-metric strong { display: block; margin-top: .2rem; font-size: 1.35rem; line-height: 1; letter-spacing: -.035em; }
.schedule-times { display: grid; grid-template-columns: 1fr 1px 1fr; align-items: center; gap: .7rem; margin-top: .75rem; padding: .15rem .25rem .25rem; }
.schedule-times > span { display: grid; justify-items: center; gap: .18rem; text-align: center; }
.schedule-times small { color: var(--widget-muted); font-size: .6rem; }
.schedule-times strong { font-size: 1.35rem; letter-spacing: -.035em; }
.schedule-times > i { width: 1px; height: 2.75rem; background: var(--tile-edge-soft); }
.open-panel {
min-height: 3.25rem;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: .65rem;
padding: .65rem .9rem;
border: 1px solid rgba(194,232,255,.84);
border-radius: 1rem;
color: white;
background:
radial-gradient(circle at 18% -35%, rgba(255,255,255,.52), transparent 47%),
linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%),
linear-gradient(145deg, rgba(60,174,239,.92), rgba(17,113,194,.88));
box-shadow: 0 9px 20px rgba(15,92,183,.2), 0 0 9px rgba(48,151,239,.11), inset 0 1px 0 rgba(255,255,255,.84), inset 0 -3px 7px rgba(0,67,156,.15);
cursor: pointer;
font-size: .78rem;
font-weight: 760;
transition: transform .15s ease, filter .15s ease, box-shadow .15s ease;
}
.open-panel svg { width: 1.45rem; height: 1.45rem; }
.open-panel b { font-size: 1rem; }
.open-panel:hover { transform: translateY(-1px); filter: brightness(1.05); box-shadow: 0 11px 23px rgba(20,101,190,.23), 0 0 11px rgba(48,151,239,.13), inset 0 1px 0 rgba(255,255,255,.86); }
.open-panel:active { transform: scale(.99); }
.demo-note { color: var(--widget-muted); text-align: center; font-size: .58rem; }
:root[data-theme="dark"] .brand-icon { border-color: rgba(193,228,245,.25); background: radial-gradient(circle at 25% 2%, rgba(208,243,255,.16), transparent 43%), linear-gradient(145deg, rgba(38,125,187,.27), rgba(15,61,91,.26)); }
:root[data-theme="dark"] .hero-step { color: #76c7fa; border-color: rgba(192,226,244,.25); background: radial-gradient(circle at 24% 5%, rgba(213,240,252,.11), transparent 40%), linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); box-shadow: 0 9px 21px rgba(0,0,0,.23), inset 0 1px 0 rgba(226,246,255,.12); }
:root[data-theme="dark"] .hero-step.primary { color: white; background: radial-gradient(circle at 24% 5%, rgba(214,246,255,.32), transparent 38%), linear-gradient(135deg, rgba(255,255,255,.08), transparent 42%, rgba(0,32,95,.17)), linear-gradient(145deg, #33aaf4, #0869d0 74%); box-shadow: 0 10px 24px rgba(0,53,119,.38), 0 0 11px rgba(41,162,244,.19), inset 0 1px 0 rgba(220,248,255,.4), inset 0 -3px 7px rgba(0,27,91,.27); }
:root[data-theme="dark"] .speed-segments button { border-color: rgba(192,226,244,.25); background: linear-gradient(180deg, rgba(91,136,162,.23), rgba(24,55,74,.28)); }
:root[data-theme="dark"] .speed-segments button.active { border-color: rgba(117,207,255,.48); background: linear-gradient(180deg, rgba(216,246,255,.19), transparent 48%), linear-gradient(90deg, #0877df, #20b7ff); box-shadow: 0 0 7px rgba(24,156,255,.27), inset 0 1px 0 rgba(223,248,255,.34); }
:root[data-theme="dark"] .glass-switch { color: #ddecf4; border-color: rgba(193,228,245,.25); background: linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); text-shadow: 0 1px 2px rgba(0,0,0,.4); }
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green,
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { color: #effff6; border-color: rgba(127,242,182,.4); background: radial-gradient(circle at 25% -8%, rgba(218,255,236,.3), transparent 42%), linear-gradient(132deg, rgba(255,255,255,.07), transparent 40%, rgba(0,45,23,.12)), linear-gradient(145deg, rgba(51,190,119,.52), rgba(9,102,59,.49)); box-shadow: 0 9px 21px rgba(0,0,0,.22), 0 0 9px rgba(37,207,121,.1), inset 0 1px 0 rgba(218,255,236,.32), inset 0 -3px 8px rgba(0,31,16,.16); }
:root[data-theme="dark"] .air-card { --air-track: rgba(8,28,39,.38); }
:root[data-theme="dark"] .air-card[data-air-tone="good"] { --air-glow: rgba(30, 224, 128, .3); }
:root[data-theme="dark"] .air-card[data-air-tone="warn"] { --air-glow: rgba(255, 176, 49, .3); }
:root[data-theme="dark"] .air-card[data-air-tone="bad"] { --air-glow: rgba(255, 82, 102, .32); }
:root[data-theme="dark"] .co2-meter { border-color: rgba(205,235,248,.3); box-shadow: inset 0 1px 4px rgba(0,0,0,.27), 0 1px 5px rgba(0,0,0,.12); }
:root[data-theme="dark"] .mini-metric { background: linear-gradient(145deg, rgba(196,231,247,.055), rgba(3,24,37,.08)); box-shadow: inset 0 1px 0 rgba(224,246,255,.07); }
/* Wide monitor layout. The same markup stays vertical on narrow screens, so
controls and API behaviour cannot drift between two widget versions. */
:root[data-widget-layout="horizontal"] body {
padding: .36rem;
}
:root[data-widget-layout="horizontal"] .widget {
width: min(100%, 76rem);
max-width: none;
gap: .48rem;
padding: .58rem;
border-radius: 1.35rem;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, .8fr) minmax(0, .8fr);
grid-template-areas:
"header header header header"
"speed temperature power heater"
"air air schedule schedule"
"open open open open"
"demo demo demo demo";
align-items: stretch;
}
:root[data-widget-layout="horizontal"] .widget-header {
grid-area: header;
min-height: 2.6rem;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: .35rem .55rem;
padding: 0 .12rem;
}
:root[data-widget-layout="horizontal"] .widget-header .identity { grid-column: 1; grid-row: 1; }
:root[data-widget-layout="horizontal"] .widget-header .status-row { grid-column: 2; grid-row: 1; }
:root[data-widget-layout="horizontal"] .widget-header .theme-button { grid-column: 3; grid-row: 1; }
:root[data-widget-layout="horizontal"] .identity { gap: .55rem; }
:root[data-widget-layout="horizontal"] .brand-icon { width: 2.45rem; height: 2.45rem; border-radius: .82rem; }
:root[data-widget-layout="horizontal"] .brand-icon svg { width: 1.48rem; height: 1.48rem; }
:root[data-widget-layout="horizontal"] .identity h1 { font-size: 1.42rem; }
:root[data-widget-layout="horizontal"] .identity-copy > small { margin-top: .18rem; font-size: .61rem; }
:root[data-widget-layout="horizontal"] .title-row { gap: .42rem; }
:root[data-widget-layout="horizontal"] .widget .pill { min-height: 1.52rem; padding: .2rem .5rem; font-size: .54rem; }
:root[data-widget-layout="horizontal"] .mode-detail { font-size: .59rem; }
:root[data-widget-layout="horizontal"] .theme-button { width: 2.2rem; height: 2.2rem; border-radius: .75rem; }
:root[data-widget-layout="horizontal"] .speed-card { grid-area: speed; }
:root[data-widget-layout="horizontal"] .temperature-card { grid-area: temperature; }
:root[data-widget-layout="horizontal"] .control-card {
min-height: 8.75rem;
padding: .66rem .78rem .58rem;
}
:root[data-widget-layout="horizontal"] .card-title { gap: .5rem; }
:root[data-widget-layout="horizontal"] .card-title h2,
:root[data-widget-layout="horizontal"] .switch-title h2,
:root[data-widget-layout="horizontal"] .section-title h2 { font-size: .86rem; }
:root[data-widget-layout="horizontal"] .card-title p { margin-top: .1rem; font-size: .58rem; }
:root[data-widget-layout="horizontal"] .feature-icon { width: 1.65rem; height: 1.65rem; }
:root[data-widget-layout="horizontal"] .hero-control {
grid-template-columns: 2.75rem 1fr 2.75rem;
gap: .55rem;
margin: .46rem 0 .34rem;
}
:root[data-widget-layout="horizontal"] .hero-step { width: 2.75rem; height: 2.75rem; border-radius: .78rem; font-size: 1.5rem; }
:root[data-widget-layout="horizontal"] .hero-value { font-size: 2.4rem; }
:root[data-widget-layout="horizontal"] .speed-segments button { height: .68rem; }
:root[data-widget-layout="horizontal"] .scale-labels,
:root[data-widget-layout="horizontal"] .temperature-labels { margin-top: .2rem; font-size: .52rem; }
:root[data-widget-layout="horizontal"] .temperature-range { height: 1.05rem; }
:root[data-widget-layout="horizontal"] .temperature-range::-webkit-slider-runnable-track { height: .55rem; }
:root[data-widget-layout="horizontal"] .temperature-range::-moz-range-track { height: .55rem; }
:root[data-widget-layout="horizontal"] .temperature-range::-webkit-slider-thumb { width: 1.32rem; height: 1.32rem; margin-top: -.4rem; border-width: .21rem; }
:root[data-widget-layout="horizontal"] .temperature-range::-moz-range-thumb { width: .9rem; height: .9rem; border-width: .21rem; }
:root[data-widget-layout="horizontal"] .switch-grid { display: contents; }
:root[data-widget-layout="horizontal"] .switch-grid > * { z-index: 1; }
:root[data-widget-layout="horizontal"] .power-card { grid-area: power; }
:root[data-widget-layout="horizontal"] .heater-card { grid-area: heater; }
:root[data-widget-layout="horizontal"] .switch-card {
min-height: 8.75rem;
gap: .45rem;
padding: .66rem .68rem .58rem;
}
:root[data-widget-layout="horizontal"] .switch-title { gap: .4rem; }
:root[data-widget-layout="horizontal"] .switch-title small { font-size: .52rem; }
:root[data-widget-layout="horizontal"] .switch-icon { width: 1.5rem; height: 1.5rem; }
:root[data-widget-layout="horizontal"] .glass-switch { min-height: 2.7rem; border-radius: .78rem; font-size: 1.05rem; }
:root[data-widget-layout="horizontal"] .info-card { padding: .62rem .68rem; }
:root[data-widget-layout="horizontal"] .section-icon { width: 1.5rem; height: 1.5rem; }
:root[data-widget-layout="horizontal"] .open-section { width: 1.75rem; height: 1.75rem; border-radius: .58rem; font-size: .82rem; }
:root[data-widget-layout="horizontal"] .air-card {
grid-area: air;
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(10.8rem, .82fr);
grid-template-rows: auto auto auto auto;
column-gap: .72rem;
align-content: start;
}
:root[data-widget-layout="horizontal"] .air-card .section-title { grid-column: 1 / -1; grid-row: 1; }
:root[data-widget-layout="horizontal"] .air-card .co2-summary {
grid-column: 1;
grid-row: 2;
margin: .42rem .05rem .3rem;
}
:root[data-widget-layout="horizontal"] .air-card .co2-meter { grid-column: 1; grid-row: 3; }
:root[data-widget-layout="horizontal"] .air-card .meter-labels { grid-column: 1; grid-row: 4; }
:root[data-widget-layout="horizontal"] .air-card .mini-metrics {
grid-column: 2;
grid-row: 2 / 5;
align-self: stretch;
gap: .38rem;
margin-top: .4rem;
}
:root[data-widget-layout="horizontal"] .air-card .mini-metric {
min-height: 0;
flex-direction: column;
gap: .14rem;
padding: .35rem .25rem;
border-radius: .72rem;
}
:root[data-widget-layout="horizontal"] .air-card .metric-icon { width: 1.25rem; height: 1.25rem; }
:root[data-widget-layout="horizontal"] .air-card .mini-metric small { font-size: .51rem; }
:root[data-widget-layout="horizontal"] .air-card .mini-metric strong { margin-top: .1rem; font-size: 1.08rem; }
:root[data-widget-layout="horizontal"] .air-card .co2-summary strong { font-size: 1.65rem; }
:root[data-widget-layout="horizontal"] .air-card .quality-chip { padding: .25rem .48rem; font-size: .54rem; }
:root[data-widget-layout="horizontal"] .co2-meter { height: .62rem; }
:root[data-widget-layout="horizontal"] .schedule-card { grid-area: schedule; }
:root[data-widget-layout="horizontal"] .air-card,
:root[data-widget-layout="horizontal"] .schedule-card { min-height: 6.9rem; }
:root[data-widget-layout="horizontal"] .schedule-times {
gap: .5rem;
margin-top: .42rem;
padding: .08rem .18rem .12rem;
}
:root[data-widget-layout="horizontal"] .schedule-times small { font-size: .53rem; }
:root[data-widget-layout="horizontal"] .schedule-times strong { font-size: 1.18rem; }
:root[data-widget-layout="horizontal"] .schedule-times > i { height: 2.2rem; }
:root[data-widget-layout="horizontal"] .open-panel {
grid-area: open;
min-height: 2.45rem;
gap: .5rem;
padding: .38rem .75rem;
border-radius: .82rem;
font-size: .68rem;
}
:root[data-widget-layout="horizontal"] .open-panel svg { width: 1.15rem; height: 1.15rem; }
:root[data-widget-layout="horizontal"] .open-panel b { font-size: .85rem; }
:root[data-widget-layout="horizontal"] .demo-note { grid-area: demo; }
button:disabled { opacity: .58; cursor: default; transform: none !important; }
@media (min-width: 560px) and (min-height: 900px) {
html { font-size: 17px; }
:root[data-widget-layout="vertical"] .widget { width: min(100%, 33rem); }
}
@media (max-width: 380px) {
body { padding: .36rem; }
.widget { padding: .55rem; gap: .52rem; border-radius: 1.2rem; }
.brand-icon { width: 2.55rem; height: 2.55rem; }
.identity h1 { font-size: 1.38rem; }
.title-row { gap: .38rem; }
.widget .pill { padding-inline: .46rem; font-size: .54rem; }
.hero-control { grid-template-columns: 3.15rem 1fr 3.15rem; gap: .55rem; }
.hero-step { width: 3.15rem; height: 3.15rem; }
.hero-value { font-size: 2.65rem; }
.switch-grid { gap: .5rem; }
.switch-card { padding: .7rem; }
.switch-title { align-items: flex-start; }
.switch-title small { display: none; }
.co2-summary { grid-template-columns: auto 1fr; }
.co2-summary .quality-chip { grid-column: 1 / -1; justify-self: center; }
}
@media (prefers-reduced-motion: reduce) {
.hero-step:hover, .theme-button:hover, .open-section:hover, .glass-switch:hover, .open-panel:hover { transform: none; }
}
+303
View File
@@ -0,0 +1,303 @@
const query = new URLSearchParams(window.location.search);
export const isDemo = query.get("demo") === "1";
const demoState = {
online: true,
running: true,
last_seen: new Date().toISOString(),
last_error: null,
tion: {
power: true,
heater: true,
heating: false,
sound: true,
mode: "outside",
out_temp: 21,
in_temp: 20,
target_temp: 21,
fan_speed: 3,
filter_remain: 136.7,
device_time: "22:45",
request_error_code: 0,
model: "S4",
light: true,
},
auto: {
available: true,
state: "inactive",
reason: null,
target_speed: null,
auto_speed: null,
target_heater: null,
auto_heater: null,
temperature: 25.9,
temperature_source: "qingping",
last_error: null,
config_error: null,
},
qingping: {
online: true,
temperature: 25.9,
humidity: 69.4,
co2: 780,
pm25: 0,
pm10: 0,
battery: 100,
last_error: null,
},
schedule: {
available: true,
enabled: true,
running: true,
paused: false,
current_action: "set",
current_time: "22:45",
next_time: "10:00",
next_action: "set",
override_until_time: "10:00",
auto_active: false,
auto_fallback_speed: 2,
auto_target_temp: 21,
scheduled_settings: { speed: 2, target_temp: 21, heater: true },
override_active: true,
override_until: new Date(Date.now() + 8 * 3600_000).toISOString(),
override_settings: { speed: 3 },
last_error: null,
},
};
const demoScheduleConfig = {
version: 1,
enabled: true,
timezone: "local",
templates: {
workday: [
{ time: "07:30", action: { type: "set", power: true, speed: 3, heater: true, target_temp: 21 } },
{ time: "09:00", action: { type: "auto", speed: 2, target_temp: 21 } },
{ time: "22:45", action: { type: "set", speed: 1 } },
],
weekend: [
{ time: "09:30", action: { type: "set", power: true, speed: 3, heater: true, target_temp: 21 } },
{ time: "11:00", action: { type: "auto", speed: 2, target_temp: 21 } },
{ time: "23:00", action: { type: "set", speed: 1 } },
],
},
days: { mon: "workday", tue: "workday", wed: "workday", thu: "workday", fri: "workday", sat: "weekend", sun: "weekend" },
};
const copy = value => JSON.parse(JSON.stringify(value));
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
function mutateDemo(path) {
const tion = demoState.tion;
const schedule = demoState.schedule;
let match;
if ((match = path.match(/^\/api\/tion\/speed\/(\d)$/))) {
tion.fan_speed = Number(match[1]);
schedule.override_active = true;
schedule.override_until_time ||= schedule.next_time;
schedule.override_settings.speed = tion.fan_speed;
} else if ((match = path.match(/^\/api\/tion\/temperature\/(\d+)$/))) {
tion.target_temp = Number(match[1]);
schedule.override_active = true;
schedule.override_until_time ||= schedule.next_time;
schedule.override_settings.target_temp = tion.target_temp;
} else if ((match = path.match(/^\/api\/tion\/(power|heater|sound|light)\/(on|off)$/))) {
tion[match[1]] = match[2] === "on";
schedule.override_active = true;
schedule.override_settings[match[1]] = tion[match[1]];
} else if ((match = path.match(/^\/api\/tion\/mode\/(outside|recirculation)$/))) {
tion.mode = match[1];
schedule.override_active = true;
schedule.override_settings.mode = match[1];
} else if (path === "/api/schedule/override/clear") {
schedule.override_active = false;
schedule.override_until = null;
schedule.override_until_time = null;
schedule.override_settings = {};
} else if (path === "/api/schedule/pause") {
schedule.paused = true;
schedule.override_active = false;
schedule.override_until_time = null;
} else if (path === "/api/schedule/resume") {
schedule.paused = false;
}
demoState.last_seen = new Date().toISOString();
}
async function request(path, options = {}) {
if (isDemo) {
await wait(options.method === "POST" ? 220 : 80);
if (path === "/api/schedule/config") {
if (options.method === "PUT") {
const replacement = JSON.parse(options.body);
Object.keys(demoScheduleConfig).forEach(key => delete demoScheduleConfig[key]);
Object.assign(demoScheduleConfig, replacement);
return { ok: true, config: copy(demoScheduleConfig) };
}
return copy(demoScheduleConfig);
}
if (options.method === "POST") mutateDemo(path);
return copy(demoState);
}
const response = await fetch(path, {
cache: "no-store",
headers: { Accept: "application/json" },
...options,
});
if (!response.ok) {
let message = `Ошибка ${response.status}`;
try {
const body = await response.json();
if (body.detail && typeof body.detail === "object") {
message = [body.detail.message, body.detail.error].filter(Boolean).join(": ") || message;
} else {
message = body.detail || body.message || message;
}
} catch (_) {}
throw new Error(typeof message === "string" ? message : JSON.stringify(message));
}
return response.json();
}
export const api = {
status: () => request("/api/status"),
post: path => request(path, { method: "POST" }),
scheduleConfig: () => request("/api/schedule/config"),
saveScheduleConfig: config => request("/api/schedule/config", {
method: "PUT",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify(config),
}),
};
export function configureTheme(button) {
const allowed = ["auto", "light", "dark"];
const explicit = query.get("theme");
let mode = allowed.includes(explicit) ? explicit : (localStorage.getItem("tion-theme") || "auto");
const apply = () => {
const dark = mode === "dark" || (mode === "auto" && matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
document.documentElement.dataset.themeMode = mode;
if (button) {
button.title = `Тема: ${mode === "auto" ? "системная" : mode === "dark" ? "тёмная" : "светлая"}`;
button.setAttribute("aria-label", button.title);
}
};
apply();
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => mode === "auto" && apply());
if (button) button.addEventListener("click", () => {
mode = allowed[(allowed.indexOf(mode) + 1) % allowed.length];
localStorage.setItem("tion-theme", mode);
apply();
});
return () => mode;
}
export function setRangeProgress(input) {
const min = Number(input.min);
const max = Number(input.max);
const value = Number(input.value);
input.style.setProperty("--progress", `${((value - min) / (max - min)) * 100}%`);
}
export function airQuality(co2) {
if (!Number.isFinite(Number(co2))) return { label: "Нет данных", tone: "muted", pct: 0 };
const value = Number(co2);
if (value < 700) return { label: "Отличный воздух", tone: "good", pct: value / 20 };
if (value < 900) return { label: "Хороший воздух", tone: "good", pct: value / 20 };
if (value < 1300) return { label: "Повышенный CO₂", tone: "warn", pct: value / 20 };
if (value < 1600) return { label: "Душно", tone: "warn", pct: value / 20 };
return { label: "Нужна вентиляция", tone: "bad", pct: Math.min(100, value / 20) };
}
const AIR_METER_STOPS = [
[400, [35, 143, 83]],
[700, [46, 166, 99]],
[820, [82, 181, 111]],
[900, [190, 153, 55]],
[1200, [214, 128, 45]],
[1500, [215, 83, 55]],
[2000, [188, 48, 72]],
];
const interpolateChannel = (start, end, amount) => Math.round(start + (end - start) * amount);
export function airMeterPalette(co2) {
const value = Number(co2);
if (!Number.isFinite(value)) {
return {
color: "rgb(105, 137, 158)",
glow: "rgba(83, 123, 150, .2)",
};
}
const bounded = Math.max(AIR_METER_STOPS[0][0], Math.min(AIR_METER_STOPS.at(-1)[0], value));
let lower = AIR_METER_STOPS[0];
let upper = AIR_METER_STOPS.at(-1);
for (let index = 1; index < AIR_METER_STOPS.length; index += 1) {
if (bounded <= AIR_METER_STOPS[index][0]) {
lower = AIR_METER_STOPS[index - 1];
upper = AIR_METER_STOPS[index];
break;
}
}
const amount = upper[0] === lower[0] ? 0 : (bounded - lower[0]) / (upper[0] - lower[0]);
const [red, green, blue] = lower[1].map((channel, index) => interpolateChannel(channel, upper[1][index], amount));
return {
color: `rgb(${red}, ${green}, ${blue})`,
glow: `rgba(${red}, ${green}, ${blue}, .26)`,
};
}
export function modeInfo(status) {
const schedule = status?.schedule || {};
if (!schedule.available) return { label: "Без расписания", detail: "Недоступно", tone: "muted" };
if (schedule.paused) return { label: "Ручной режим", detail: "Расписание на паузе", tone: "warn" };
if (schedule.override_active) return {
label: "Ручное управление",
detail: schedule.override_until_time ? `До ${schedule.override_until_time}` : "До следующей точки",
tone: "warn",
};
if (schedule.auto_active) return { label: "AUTO", detail: "По качеству воздуха", tone: "info" };
return { label: "Расписание", detail: "Активно", tone: "info" };
}
export const numberOrDash = (value, digits = 0) => {
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(digits) : "—";
};
export function showToast(message, error = false) {
let region = document.querySelector(".toast-region");
if (!region) {
region = document.createElement("div");
region.className = "toast-region";
document.body.append(region);
}
const toast = document.createElement("div");
toast.className = `toast${error ? " error" : ""}`;
toast.textContent = message;
region.append(toast);
setTimeout(() => toast.remove(), 3200);
}
export function panelUrl(themeMode = "auto", hash = "") {
const url = new URL("/ui/panel", window.location.origin);
url.searchParams.set("theme", themeMode);
if (isDemo) url.searchParams.set("demo", "1");
url.hash = hash;
return url.toString();
}
+476
View File
@@ -0,0 +1,476 @@
import { api, airMeterPalette, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, setRangeProgress, showToast } from "./api.js";
const $ = selector => document.querySelector(selector);
configureTheme($("#theme-button"));
let state = null;
let busy = false;
let scheduleDraft = null;
let activeTemplate = null;
const weekdays = [
["mon", "Понедельник"],
["tue", "Вторник"],
["wed", "Среда"],
["thu", "Четверг"],
["fri", "Пятница"],
["sat", "Суббота"],
["sun", "Воскресенье"],
];
const templateNames = { workday: "Будний", weekend: "Выходной" };
const clone = value => JSON.parse(JSON.stringify(value));
function templateLabel(name) {
return templateNames[name] || name;
}
if (isDemo) $("#demo-note").hidden = false;
function text(selector, value) { $(selector).textContent = value; }
function setPill(selector, label, tone) { const element = $(selector); element.textContent = label; element.className = `pill ${tone}`; }
function toggle(selector, active, label = "") {
const button = $(selector);
button.setAttribute("aria-pressed", String(Boolean(active)));
button.textContent = `${label}${label ? " " : ""}${active ? "ON" : "OFF"}`;
}
function setRange(selector, value) {
const input = $(selector);
if (Number.isFinite(Number(value))) input.value = value;
setRangeProgress(input);
}
function updateSpeedVisual(value) {
const speed = Math.max(1, Math.min(6, Number(value) || 1));
text("#speed-value", speed);
setRange("#speed-range", speed);
document.querySelectorAll(".speed-segments button").forEach((segment, index) => {
segment.classList.toggle("active", index < speed);
segment.setAttribute("aria-pressed", String(index + 1 === speed));
});
}
function updateTemperatureVisual(value) {
const temperature = Math.max(5, Math.min(30, Number(value) || 5));
text("#temperature-value", temperature);
setRange("#temperature-range", temperature);
}
function actionLabel(value) {
return ({ set: "Настройка режима", auto: "Автоматический режим", off: "Выключение" })[value] || "Точка расписания";
}
function renderAir(sensor, tion) {
const quality = airQuality(sensor.co2);
const airTone = ["good", "warn", "bad"].includes(quality.tone) ? quality.tone : "muted";
const airPalette = airMeterPalette(sensor.co2);
const airCard = $(".co2-card");
text("#co2", numberOrDash(sensor.co2));
text("#air-label", quality.label);
text("#air-advice", quality.tone === "good" ? "Проветривание работает нормально" : quality.tone === "warn" ? "Автоматика при необходимости повысит скорость" : quality.tone === "bad" ? "Рекомендуется усилить вентиляцию" : "Ожидаем показания датчика");
$("#air-label").className = `quality-chip ${airTone}`;
airCard.dataset.airTone = airTone;
airCard.style.setProperty("--air-color", airPalette.color);
airCard.style.setProperty("--air-glow", airPalette.glow);
$("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`;
setPill("#sensor-status", sensor.online ? "Online" : "Offline", sensor.online ? "good" : "bad");
text("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1));
text("#humidity", numberOrDash(sensor.humidity));
text("#pm25", numberOrDash(sensor.pm25));
text("#pm10", numberOrDash(sensor.pm10));
const pm25 = Number(sensor.pm25);
const pm10 = Number(sensor.pm10);
text("#pm25-quality", Number.isFinite(pm25) ? (pm25 <= 15 ? "Отлично" : pm25 <= 35 ? "Норма" : "Повышено") : "—");
text("#pm10-quality", Number.isFinite(pm10) ? (pm10 <= 30 ? "Отлично" : pm10 <= 60 ? "Норма" : "Повышено") : "—");
}
function renderSchedule(status) {
const schedule = status.schedule || {};
const mode = modeInfo(status);
text("#current-time", schedule.current_time || status.tion?.device_time || "—");
text("#next-time", schedule.next_time || "—");
text("#current-action", actionLabel(schedule.current_action));
text("#next-action", actionLabel(schedule.next_action));
text("#mode-title", mode.label);
text("#mode-detail", mode.detail);
setPill("#schedule-state", schedule.paused ? "Пауза" : schedule.enabled ? "Активно" : "Выкл", schedule.paused ? "warn" : schedule.enabled ? "info" : "muted");
const headerMode = schedule.override_active && schedule.override_until_time
? `Ручной до ${schedule.override_until_time}`
: mode.label;
setPill("#mode-pill", headerMode, mode.tone);
$("#mode-banner").className = `mode-banner ${mode.tone === "info" ? "info" : mode.tone === "muted" ? "muted" : "warn"}`;
$("#auto-button").disabled = !schedule.available || (!schedule.override_active && !schedule.paused);
$("#pause-button strong").textContent = schedule.paused ? "Возобновить расписание" : "Пауза расписания";
$("#pause-button small").textContent = schedule.paused ? "Продолжить по расписанию" : "Временно приостановить";
}
function render(status) {
state = status;
const tion = status.tion || {};
const sensor = status.qingping || {};
setPill("#connection", status.online ? "Online" : "Offline", status.online ? "good" : "bad");
updateSpeedVisual(tion.fan_speed);
updateTemperatureVisual(tion.target_temp);
text("#speed-note", status.auto?.state === "active" && status.auto?.target_speed ? `AUTO выбрал скорость ${status.auto.target_speed}` : `Ступень ${numberOrDash(tion.fan_speed)} из 6`);
text("#temperature-note", tion.heating ? "Сейчас нагревает входящий воздух" : "Желаемая температура в помещении");
toggle("#power-toggle", tion.power);
toggle("#heater-toggle", tion.heater);
toggle("#sound-toggle", tion.sound, "Звук");
toggle("#light-toggle", tion.light, "Свет");
text("#power-caption", tion.power ? "Устройство включено" : "Устройство выключено");
text("#heater-caption", tion.heating ? "Сейчас нагревает воздух" : "Поддержание температуры");
$("#mode-outside").classList.toggle("active", tion.mode === "outside");
$("#mode-recirculation").classList.toggle("active", tion.mode === "recirculation");
text("#mode-name", tion.mode === "recirculation" ? "Рециркуляция" : "Приточная вентиляция");
text("#outside-temp", numberOrDash(tion.out_temp));
text("#filter-remain", numberOrDash(tion.filter_remain));
const filterPercent = Math.max(0, Math.min(100, Number(tion.filter_remain || 0) / 2));
$("#filter-progress").style.width = `${filterPercent}%`;
text("#filter-percent", `${Math.round(filterPercent)}%`);
text("#model", tion.model || "—");
text("#battery", numberOrDash(sensor.battery));
renderAir(sensor, tion);
renderSchedule(status);
text("#updated-at", `Обновлено ${new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`);
}
async function refresh(silent = false) {
try { render(await api.status()); }
catch (error) {
setPill("#connection", "Ошибка связи", "bad");
if (!silent) showToast(error.message, true);
}
}
async function act(path, source) {
if (busy) return;
busy = true;
source?.setAttribute("disabled", "");
source?.setAttribute("aria-busy", "true");
try { await api.post(path); await refresh(true); }
catch (error) { showToast(error.message, true); await refresh(true); }
finally { busy = false; source?.removeAttribute("disabled"); source?.removeAttribute("aria-busy"); }
}
function stepped(kind, delta, source) {
if (busy) return;
const isSpeed = kind === "speed";
const current = Number(isSpeed ? state?.tion?.fan_speed : state?.tion?.target_temp);
const min = isSpeed ? 1 : 5;
const max = isSpeed ? 6 : 30;
const value = Math.min(max, Math.max(min, current + delta));
if (Number.isFinite(value) && value !== current) {
if (isSpeed) {
state.tion.fan_speed = value;
updateSpeedVisual(value);
} else {
state.tion.target_temp = value;
updateTemperatureVisual(value);
}
act(`/api/tion/${isSpeed ? "speed" : "temperature"}/${value}`, source);
}
}
function optionList(values, current, emptyLabel = "Не менять") {
const items = emptyLabel === null ? [] : [["", emptyLabel]];
items.push(...values);
return items.map(([value, label]) => `<option value="${value}"${String(current ?? "") === String(value) ? " selected" : ""}>${label}</option>`).join("");
}
function booleanSetting(value) {
return value === true ? "on" : value === false ? "off" : "";
}
function pointMarkup(point, index) {
const action = point?.action || {};
const type = action.type === "auto" ? "auto" : "set";
const speedOptions = Array.from({ length: 6 }, (_, item) => [String(item + 1), String(item + 1)]);
const booleanOptions = [["on", "Включить"], ["off", "Выключить"]];
return `
<article class="point-editor ${type}" data-index="${index}">
<div class="point-header">
<span class="point-number">${index + 1}</span>
<label>Время<input data-field="time" type="time" value="${point?.time || ""}" required></label>
<label class="point-action">Действие<select data-field="type"><option value="set"${type === "set" ? " selected" : ""}>Задать параметры</option><option value="auto"${type === "auto" ? " selected" : ""}>AUTO по воздуху</option></select></label>
<button class="remove-point" data-remove-point="${index}" type="button" aria-label="Удалить точку">×</button>
</div>
<div class="point-settings">
<label class="point-setting">Скорость<select data-setting="speed">${optionList(speedOptions, action.speed, "Не менять")}</select></label>
<label class="point-setting">Температура<input data-setting="target_temp" type="number" min="5" max="30" step="1" value="${action.target_temp ?? ""}" placeholder="—"></label>
<label class="point-setting" data-set-only>Питание<select data-setting="power">${optionList(booleanOptions, booleanSetting(action.power))}</select></label>
<label class="point-setting" data-set-only>Обогрев<select data-setting="heater">${optionList(booleanOptions, booleanSetting(action.heater))}</select></label>
<label class="point-setting" data-set-only>Воздух<select data-setting="mode">${optionList([["outside", "С улицы"], ["recirculation", "Рециркуляция"]], action.mode)}</select></label>
<label class="point-setting" data-set-only>Звук<select data-setting="sound">${optionList(booleanOptions, booleanSetting(action.sound))}</select></label>
<label class="point-setting" data-set-only>Подсветка<select data-setting="light">${optionList(booleanOptions, booleanSetting(action.light))}</select></label>
</div>
</article>`;
}
function readRenderedPoints(strict = false) {
const points = [...document.querySelectorAll(".point-editor")].map(card => {
const time = card.querySelector('[data-field="time"]').value;
const type = card.querySelector('[data-field="type"]').value;
const action = { type };
card.querySelectorAll("[data-setting]").forEach(input => {
const field = input.dataset.setting;
const raw = input.value;
if (raw === "" || (type === "auto" && !["speed", "target_temp"].includes(field))) return;
if (["speed", "target_temp"].includes(field)) action[field] = Number(raw);
else if (["power", "heater", "sound", "light"].includes(field)) action[field] = raw === "on";
else action[field] = raw;
});
return { time, action };
});
if (strict) validateTemplate(activeTemplate, points);
return points;
}
function validateTemplate(name, points) {
const label = templateLabel(name);
if (!Array.isArray(points) || !points.length) throw new Error(`Шаблон «${label}» не может быть пустым`);
const times = new Set();
points.forEach((point, index) => {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(point.time || "")) throw new Error(`Проверьте время в точке ${index + 1} шаблона «${label}»`);
if (times.has(point.time)) throw new Error(`В шаблоне «${label}» время ${point.time} указано дважды`);
times.add(point.time);
const action = point.action || {};
if (!["set", "auto"].includes(action.type)) throw new Error(`Неизвестное действие в шаблоне «${label}»`);
if (action.speed !== undefined && (!Number.isInteger(action.speed) || action.speed < 1 || action.speed > 6)) throw new Error(`Скорость в шаблоне «${label}» должна быть от 1 до 6`);
if (action.target_temp !== undefined && (!Number.isInteger(action.target_temp) || action.target_temp < 5 || action.target_temp > 30)) throw new Error(`Температура в шаблоне «${label}» должна быть от 5 до 30°`);
if (action.type === "auto" && action.speed === undefined) throw new Error(`Для AUTO в шаблоне «${label}» укажите базовую скорость`);
if (action.type === "set" && Object.keys(action).length === 1) throw new Error(`В точке ${point.time} шаблона «${label}» не выбран ни один параметр`);
});
}
function validateScheduleDraft() {
const names = Object.keys(scheduleDraft?.templates || {});
if (!names.length) throw new Error("В расписании должен быть хотя бы один шаблон");
names.forEach(name => validateTemplate(name, scheduleDraft.templates[name]));
weekdays.forEach(([day, label]) => {
if (!names.includes(scheduleDraft.days?.[day])) throw new Error(`Для дня «${label}» не выбран шаблон`);
});
}
function commitActiveTemplate(strict = false) {
if (scheduleDraft && activeTemplate) scheduleDraft.templates[activeTemplate] = readRenderedPoints(strict);
}
function renderTemplateUsage() {
const usedBy = weekdays.filter(([day]) => scheduleDraft.days?.[day] === activeTemplate).map(([, label]) => label.toLowerCase());
text("#template-usage", usedBy.length ? `Используют: ${usedBy.join(", ")}` : "Пока не назначен ни одному дню");
}
function renderTemplateSelector() {
const select = $("#template-select");
select.replaceChildren(...Object.keys(scheduleDraft.templates).map(name => new Option(templateLabel(name), name, false, name === activeTemplate)));
$("#delete-template").disabled = Object.keys(scheduleDraft.templates).length <= 1;
}
function renderDayAssignments() {
const host = $("#day-assignments");
const templateNames = Object.keys(scheduleDraft.templates);
host.replaceChildren(...weekdays.map(([day, label]) => {
const wrapper = document.createElement("label");
wrapper.className = "day-assignment";
wrapper.textContent = label;
const select = document.createElement("select");
select.dataset.day = day;
select.replaceChildren(...templateNames.map(name => new Option(templateLabel(name), name, false, scheduleDraft.days?.[day] === name)));
wrapper.append(select);
return wrapper;
}));
}
function renderSchedulePoints() {
const points = scheduleDraft.templates[activeTemplate] || [];
$("#schedule-points").innerHTML = points.length
? points.map(pointMarkup).join("")
: '<div class="empty-points">В этом шаблоне пока нет точек</div>';
renderTemplateUsage();
}
function setEditorStatus(message = "", error = false) {
const status = $("#schedule-editor-status");
status.textContent = message;
status.classList.toggle("error", error);
}
async function openScheduleEditor() {
const source = $("#schedule-edit-button");
source.disabled = true;
source.setAttribute("aria-busy", "true");
try {
scheduleDraft = clone(await api.scheduleConfig());
const names = Object.keys(scheduleDraft.templates || {});
if (!names.length) throw new Error("В расписании нет шаблонов");
const dayKeys = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
activeTemplate = scheduleDraft.days?.[dayKeys[new Date().getDay()]] || names[0];
$("#schedule-enabled").checked = scheduleDraft.enabled !== false;
renderTemplateSelector();
renderDayAssignments();
renderSchedulePoints();
setEditorStatus();
$("#schedule-dialog").showModal();
} catch (error) {
showToast(`Не удалось открыть редактор: ${error.message}`, true);
} finally {
source.disabled = false;
source.removeAttribute("aria-busy");
}
}
function closeScheduleEditor() {
$("#schedule-dialog").close();
scheduleDraft = null;
activeTemplate = null;
}
async function saveScheduleEditor(event) {
event.preventDefault();
if (!scheduleDraft) return;
const saveButton = $("#schedule-save-button");
try {
commitActiveTemplate(true);
scheduleDraft.enabled = $("#schedule-enabled").checked;
validateScheduleDraft();
Object.values(scheduleDraft.templates).forEach(points => points.sort((left, right) => left.time.localeCompare(right.time)));
setEditorStatus("Сохраняем…");
saveButton.disabled = true;
saveButton.setAttribute("aria-busy", "true");
const result = await api.saveScheduleConfig(scheduleDraft);
$("#schedule-dialog").close();
scheduleDraft = null;
activeTemplate = null;
showToast(result.restart_required ? "Расписание сохранено. Перезапустите сервис для применения" : "Расписание сохранено и применено");
await refresh(true);
} catch (error) {
setEditorStatus(error.message, true);
} finally {
saveButton.disabled = false;
saveButton.removeAttribute("aria-busy");
}
}
document.querySelectorAll("[data-step]").forEach(button => button.addEventListener("click", () => stepped(button.dataset.step, Number(button.dataset.delta), button)));
document.querySelectorAll(".speed-segments button").forEach(button => button.addEventListener("click", () => {
if (busy || !state?.tion) return;
const value = Number(button.dataset.speed);
state.tion.fan_speed = value;
updateSpeedVisual(value);
act(`/api/tion/speed/${value}`, button);
}));
$("#speed-range").addEventListener("input", event => updateSpeedVisual(event.target.value));
$("#speed-range").addEventListener("change", event => act(`/api/tion/speed/${event.target.value}`, event.target));
$("#temperature-range").addEventListener("input", event => { setRangeProgress(event.target); text("#temperature-value", event.target.value); });
$("#temperature-range").addEventListener("change", event => act(`/api/tion/temperature/${event.target.value}`, event.target));
$("#power-toggle").addEventListener("click", event => act(`/api/tion/power/${state?.tion?.power ? "off" : "on"}`, event.currentTarget));
$("#heater-toggle").addEventListener("click", event => act(`/api/tion/heater/${state?.tion?.heater ? "off" : "on"}`, event.currentTarget));
$("#sound-toggle").addEventListener("click", event => act(`/api/tion/sound/${state?.tion?.sound ? "off" : "on"}`, event.currentTarget));
$("#light-toggle").addEventListener("click", event => act(`/api/tion/light/${state?.tion?.light ? "off" : "on"}`, event.currentTarget));
$("#mode-outside").addEventListener("click", event => act("/api/tion/mode/outside", event.currentTarget));
$("#mode-recirculation").addEventListener("click", event => act("/api/tion/mode/recirculation", event.currentTarget));
$("#auto-button").addEventListener("click", event => act(state?.schedule?.paused ? "/api/schedule/resume" : "/api/schedule/override/clear", event.currentTarget));
$("#pause-button").addEventListener("click", event => act(state?.schedule?.paused ? "/api/schedule/resume" : "/api/schedule/pause", event.currentTarget));
$("#refresh-button").addEventListener("click", event => { event.currentTarget.animate([{ transform: "rotate(0)" }, { transform: "rotate(360deg)" }], { duration: 450 }); refresh(); });
$("#schedule-edit-button").addEventListener("click", openScheduleEditor);
$("#schedule-close-button").addEventListener("click", closeScheduleEditor);
$("#schedule-cancel-button").addEventListener("click", closeScheduleEditor);
$("#schedule-form").addEventListener("submit", saveScheduleEditor);
$("#schedule-dialog").addEventListener("close", () => { scheduleDraft = null; activeTemplate = null; });
$("#template-select").addEventListener("change", event => {
if (!scheduleDraft) return;
commitActiveTemplate(false);
activeTemplate = event.target.value;
renderSchedulePoints();
});
$("#duplicate-template").addEventListener("click", () => {
if (!scheduleDraft || !activeTemplate) return;
commitActiveTemplate(false);
const base = `${activeTemplate}-copy`;
let name = base;
let suffix = 2;
while (scheduleDraft.templates[name]) name = `${base}-${suffix++}`;
scheduleDraft.templates[name] = clone(scheduleDraft.templates[activeTemplate]);
activeTemplate = name;
renderTemplateSelector();
renderDayAssignments();
renderSchedulePoints();
setEditorStatus(`Создан шаблон «${templateLabel(name)}». Назначьте его нужным дням`);
});
$("#delete-template").addEventListener("click", () => {
if (!scheduleDraft || Object.keys(scheduleDraft.templates).length <= 1) return;
if (!window.confirm(`Удалить шаблон «${templateLabel(activeTemplate)}»?`)) return;
const removed = activeTemplate;
delete scheduleDraft.templates[removed];
activeTemplate = Object.keys(scheduleDraft.templates)[0];
weekdays.forEach(([day]) => {
if (scheduleDraft.days[day] === removed) scheduleDraft.days[day] = activeTemplate;
});
renderTemplateSelector();
renderDayAssignments();
renderSchedulePoints();
setEditorStatus(`Шаблон «${templateLabel(removed)}» удалён`);
});
$("#day-assignments").addEventListener("change", event => {
const select = event.target.closest("[data-day]");
if (!select || !scheduleDraft) return;
scheduleDraft.days[select.dataset.day] = select.value;
renderTemplateUsage();
});
$("#schedule-points").addEventListener("change", event => {
if (event.target.dataset.field !== "type") return;
const card = event.target.closest(".point-editor");
const isAuto = event.target.value === "auto";
card.classList.toggle("auto", isAuto);
card.classList.toggle("set", !isAuto);
const speed = card.querySelector('[data-setting="speed"]');
if (isAuto && !speed.value) speed.value = "2";
});
$("#schedule-points").addEventListener("click", event => {
const button = event.target.closest("[data-remove-point]");
if (!button || !scheduleDraft) return;
commitActiveTemplate(false);
scheduleDraft.templates[activeTemplate].splice(Number(button.dataset.removePoint), 1);
renderSchedulePoints();
});
$("#add-schedule-point").addEventListener("click", () => {
if (!scheduleDraft) return;
commitActiveTemplate(false);
const points = scheduleDraft.templates[activeTemplate];
const used = new Set(points.map(point => point.time));
const existingTimes = points.map(point => {
const [hours, minute] = String(point.time || "").split(":").map(Number);
return hours * 60 + minute;
}).filter(Number.isFinite);
let minutes = existingTimes.length ? (Math.max(...existingTimes) + 60) % 1440 : 8 * 60;
for (let attempt = 0; attempt < 48; attempt += 1) {
const candidate = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
if (!used.has(candidate)) {
points.push({ time: candidate, action: { type: "set", speed: 2 } });
break;
}
minutes = (minutes + 30) % 1440;
}
points.sort((left, right) => left.time.localeCompare(right.time));
renderSchedulePoints();
$("#schedule-points .point-editor:last-of-type")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
});
setRangeProgress($("#speed-range"));
setRangeProgress($("#temperature-range"));
refresh();
setInterval(() => !document.hidden && !busy && refresh(true), 3000);
+172
View File
@@ -0,0 +1,172 @@
import { api, airMeterPalette, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, panelUrl, showToast } from "./api.js";
const requestedLayout = new URLSearchParams(window.location.search).get("layout");
const layoutMode = ["horizontal", "vertical"].includes(requestedLayout) ? requestedLayout : null;
const wideLayout = window.matchMedia("(min-width: 800px)");
function applyLayout() {
document.documentElement.dataset.widgetLayout = layoutMode || (wideLayout.matches ? "horizontal" : "vertical");
}
applyLayout();
wideLayout.addEventListener("change", () => !layoutMode && applyLayout());
const $ = selector => document.querySelector(selector);
const themeMode = configureTheme($("#theme-button"));
let state = null;
let busy = false;
if (isDemo) $("#demo-note").hidden = false;
function setText(selector, value) { $(selector).textContent = value; }
function setPill(element, label, tone) { element.textContent = label; element.className = `pill ${tone}`; }
function toneClass(tone) { return ["good", "warn", "bad"].includes(tone) ? tone : "muted"; }
function updateSpeedVisual(value) {
const speed = Math.max(1, Math.min(6, Number(value) || 1));
setText("#speed", speed);
$("#speed").classList.remove("skeleton");
document.querySelectorAll("[data-speed]").forEach(button => {
const segment = Number(button.dataset.speed);
button.classList.toggle("active", segment <= speed);
button.setAttribute("aria-pressed", String(segment === speed));
});
}
function updateTemperatureVisual(value) {
const temperature = Math.max(5, Math.min(30, Number(value) || 5));
setText("#target-temp", temperature);
$("#target-temp").classList.remove("skeleton");
$("#temperature-range").value = temperature;
}
function drawToggle(selector, active) {
const button = $(selector);
button.setAttribute("aria-pressed", String(Boolean(active)));
button.textContent = active ? "ON" : "OFF";
}
function render(status) {
state = status;
const tion = status.tion || {};
const sensor = status.qingping || {};
const schedule = status.schedule || {};
const mode = modeInfo(status);
const quality = airQuality(sensor.co2);
const headerMode = schedule.override_active && schedule.override_until_time
? `Ручной до ${schedule.override_until_time}`
: mode.label;
setPill($("#connection"), status.online ? "Online" : "Offline", status.online ? "good" : "bad");
setPill($("#mode-pill"), headerMode, mode.tone);
setText("#mode-detail", mode.detail);
updateSpeedVisual(tion.fan_speed);
setText("#speed-note", status.auto?.state === "active" && status.auto?.target_speed
? `AUTO выбрал скорость ${status.auto.target_speed}`
: `Ступень ${numberOrDash(tion.fan_speed)} из 6`);
updateTemperatureVisual(tion.target_temp);
setText("#heating-note", tion.heating ? "Сейчас нагревает входящий воздух" : tion.heater ? "Обогрев разрешён" : "Желаемая температура воздуха");
drawToggle("#power-toggle", tion.power);
drawToggle("#heater-toggle", tion.heater);
setText("#power-detail", tion.power ? "Устройство включено" : "Устройство выключено");
setText("#heater-detail", tion.heating ? "Сейчас нагревает" : tion.heater ? "Автоматически" : "Выключен");
setText("#co2", numberOrDash(sensor.co2));
setText("#air-label", quality.label);
const airTone = toneClass(quality.tone);
const airPalette = airMeterPalette(sensor.co2);
const airCard = $(".air-card");
$("#air-label").className = `quality-chip ${airTone}`;
airCard.dataset.airTone = airTone;
airCard.style.setProperty("--air-color", airPalette.color);
airCard.style.setProperty("--air-glow", airPalette.glow);
$("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`;
setText("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1));
setText("#humidity", numberOrDash(sensor.humidity));
setText("#current-time", schedule.current_time || tion.device_time || "—");
setText("#next-time", schedule.next_time || "—");
}
async function refresh(silent = false) {
try { render(await api.status()); }
catch (error) {
setPill($("#connection"), "Ошибка", "bad");
if (!silent) showToast(error.message, true);
}
}
async function act(path, source) {
if (busy) return;
busy = true;
source?.setAttribute("disabled", "");
try { await api.post(path); await refresh(true); }
catch (error) { showToast(error.message, true); await refresh(true); }
finally { busy = false; source?.removeAttribute("disabled"); }
}
function stepped(kind, delta, source) {
if (busy || !state?.tion) return;
const isSpeed = kind === "speed";
const current = Number(isSpeed ? state.tion.fan_speed : state.tion.target_temp);
const min = isSpeed ? 1 : 5;
const max = isSpeed ? 6 : 30;
const value = Math.min(max, Math.max(min, current + delta));
if (!Number.isFinite(value) || value === current) return;
if (isSpeed) {
state.tion.fan_speed = value;
updateSpeedVisual(value);
} else {
state.tion.target_temp = value;
updateTemperatureVisual(value);
}
act(`/api/tion/${isSpeed ? "speed" : "temperature"}/${value}`, source);
}
document.querySelectorAll("[data-step]").forEach(button => button.addEventListener("click", () => {
stepped(button.dataset.step, Number(button.dataset.delta), button);
}));
document.querySelectorAll("[data-speed]").forEach(button => button.addEventListener("click", () => {
if (busy || !state?.tion) return;
const value = Number(button.dataset.speed);
state.tion.fan_speed = value;
updateSpeedVisual(value);
act(`/api/tion/speed/${value}`, button);
}));
$("#temperature-range").addEventListener("input", event => setText("#target-temp", event.target.value));
$("#temperature-range").addEventListener("change", event => {
if (busy || !state?.tion) return;
const value = Number(event.target.value);
state.tion.target_temp = value;
updateTemperatureVisual(value);
act(`/api/tion/temperature/${value}`, event.target);
});
$("#power-toggle").addEventListener("click", event => {
if (busy || !state?.tion) return;
const value = !state.tion.power;
state.tion.power = value;
drawToggle("#power-toggle", value);
setText("#power-detail", value ? "Устройство включено" : "Устройство выключено");
act(`/api/tion/power/${value ? "on" : "off"}`, event.currentTarget);
});
$("#heater-toggle").addEventListener("click", event => {
if (busy || !state?.tion) return;
const value = !state.tion.heater;
state.tion.heater = value;
drawToggle("#heater-toggle", value);
setText("#heater-detail", value ? "Автоматически" : "Выключен");
act(`/api/tion/heater/${value ? "on" : "off"}`, event.currentTarget);
});
$("#open-panel").addEventListener("click", () => window.open(panelUrl(themeMode()), "_blank", "noopener"));
$("#air-link").addEventListener("click", () => window.open(panelUrl(themeMode(), "air"), "_blank", "noopener"));
$("#schedule-link").addEventListener("click", () => window.open(panelUrl(themeMode(), "schedule"), "_blank", "noopener"));
refresh();
setInterval(() => !document.hidden && !busy && refresh(true), 3000);
+163
View File
@@ -0,0 +1,163 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Tion 4S — панель управления</title>
<link rel="stylesheet" href="/ui/static/css/base.css">
<link rel="stylesheet" href="/ui/static/css/panel.css">
</head>
<body>
<div class="ambient"></div>
<main class="climate-panel glass" aria-live="polite">
<header class="panel-header">
<div class="identity">
<h1>Tion 4S</h1>
<span id="connection" class="pill muted">Загрузка</span>
<span id="mode-pill" class="pill muted"></span>
</div>
<div class="header-tools">
<div class="ventilation-badge glass-tile">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 7h9a3 3 0 1 0-2.6-4.5M3 12h15a3 3 0 1 1-2.7 4.3M3 17h7" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
<span><strong>Приточная вентиляция</strong><small>Чистый воздух для вашего дома</small></span>
</div>
<button id="refresh-button" class="icon-button" type="button" title="Обновить данные">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.34 5.66M20 5v6h-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button id="theme-button" class="icon-button" type="button" title="Сменить тему">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.64 5.64l1.42 1.42m9.88 9.88 1.42 1.42m0-12.72-1.42 1.42M7.06 16.94l-1.42 1.42" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/></svg>
</button>
</div>
</header>
<section class="primary-controls" aria-label="Основное управление">
<article class="control-card glass-tile speed-card">
<div class="card-title"><svg class="ui-icon feature-icon fan-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#fan"></use></svg><span><h2>Скорость вентиляции</h2><p id="speed-note">Текущая ступень вентилятора</p></span></div>
<div class="hero-control">
<button class="hero-step" data-step="speed" data-delta="-1" type="button" aria-label="Уменьшить скорость"></button>
<strong id="speed-value" class="hero-value"></strong>
<button class="hero-step primary" data-step="speed" data-delta="1" type="button" aria-label="Увеличить скорость">+</button>
</div>
<input id="speed-range" class="range speed-range" type="range" min="1" max="6" step="1" value="3" aria-label="Скорость вентилятора">
<div class="speed-segments" aria-label="Выбор скорости">
<button type="button" data-speed="1" aria-label="Скорость 1"></button>
<button type="button" data-speed="2" aria-label="Скорость 2"></button>
<button type="button" data-speed="3" aria-label="Скорость 3"></button>
<button type="button" data-speed="4" aria-label="Скорость 4"></button>
<button type="button" data-speed="5" aria-label="Скорость 5"></button>
<button type="button" data-speed="6" aria-label="Скорость 6"></button>
</div>
<div class="speed-labels" aria-hidden="true"><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span></div>
</article>
<article class="control-card glass-tile temperature-card">
<div class="card-title"><svg class="ui-icon feature-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#thermometer"></use></svg><span><h2>Цель (температура)</h2><p id="temperature-note">Желаемая температура воздуха</p></span></div>
<div class="hero-control">
<button class="hero-step" data-step="temperature" data-delta="-1" type="button" aria-label="Уменьшить температуру"></button>
<strong class="hero-value"><span id="temperature-value"></span>°</strong>
<button class="hero-step primary" data-step="temperature" data-delta="1" type="button" aria-label="Увеличить температуру">+</button>
</div>
<input id="temperature-range" class="range temperature-range" type="range" min="5" max="30" step="1" value="21" aria-label="Целевая температура">
<div class="temperature-labels" aria-hidden="true"><span></span><span>10°</span><span>15°</span><span>20°</span><span>25°</span><span>30°</span></div>
</article>
<article class="switch-card glass-tile power-card">
<div class="card-title compact"><svg class="ui-icon feature-icon green" aria-hidden="true"><use href="/ui/static/assets/icons.svg#power"></use></svg><span><h2>Питание</h2><p id="power-caption">Устройство выключено</p></span></div>
<button id="power-toggle" class="glass-switch green" type="button" aria-pressed="false">OFF</button>
</article>
<article class="switch-card glass-tile heater-card">
<div class="card-title compact"><svg class="ui-icon feature-icon orange" aria-hidden="true"><use href="/ui/static/assets/icons.svg#heat"></use></svg><span><h2>Обогрев</h2><p id="heater-caption">Поддержание температуры</p></span></div>
<button id="heater-toggle" class="glass-switch orange" type="button" aria-pressed="false">OFF</button>
</article>
</section>
<section id="air" class="content-section glass-section" aria-labelledby="air-title">
<div class="section-title">
<div><svg class="ui-icon section-icon leaf" aria-hidden="true"><use href="/ui/static/assets/icons.svg#leaf"></use></svg><h2 id="air-title">Показатели воздуха</h2></div>
<span id="sensor-status" class="pill muted">Датчик</span>
<span id="updated-at" class="updated">Обновление…</span>
</div>
<div class="air-grid">
<article class="metric-card co2-card glass-tile" data-air-tone="muted">
<div class="metric-head"><span>CO₂</span><span id="air-label" class="quality-chip muted">Нет данных</span></div>
<strong><span id="co2"></span> <small>ppm</small></strong>
<p id="air-advice">Ожидаем показания датчика</p>
<div class="meter co2-meter"><i id="co2-progress"></i></div>
<div class="meter-labels"><span>400</span><span>800</span><span>1200</span><span>2000</span></div>
</article>
<article class="metric-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#thermometer"></use></svg><span>Температура<br>в помещении</span></div><strong><span id="room-temp"></span>°</strong></article>
<article class="metric-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#drop"></use></svg><span>Влажность</span></div><strong><span id="humidity"></span>%</strong></article>
<article class="metric-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon particles" aria-hidden="true"><use href="/ui/static/assets/icons.svg#particles"></use></svg><span>PM2.5</span></div><strong><span id="pm25"></span> <small>мкг/м³</small></strong><span id="pm25-quality" class="quality-chip small"></span></article>
<article class="metric-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon particles" aria-hidden="true"><use href="/ui/static/assets/icons.svg#particles"></use></svg><span>PM10</span></div><strong><span id="pm10"></span> <small>мкг/м³</small></strong><span id="pm10-quality" class="quality-chip small"></span></article>
<article class="metric-card filter-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#filter"></use></svg><span>Фильтр</span></div><strong><span id="filter-remain"></span> <small>дней осталось</small></strong><div class="filter-line"><div class="meter filter-meter"><i id="filter-progress"></i></div><span id="filter-percent"></span></div></article>
</div>
</section>
<div class="bottom-grid">
<section id="schedule" class="content-section glass-section schedule-section" aria-labelledby="schedule-title">
<div class="section-title"><div><svg class="ui-icon section-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#clock"></use></svg><h2 id="schedule-title">Расписание</h2></div><div class="schedule-title-tools"><button id="schedule-edit-button" class="schedule-edit-button" type="button"><svg class="ui-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#settings"></use></svg><span>Изменить</span></button><span id="schedule-state" class="pill muted"></span></div></div>
<div class="schedule-layout">
<div class="schedule-times glass-tile">
<div><span>Текущая точка</span><strong id="current-time"></strong><small id="current-action">Нет данных</small></div><i></i><div><span>Следующая точка</span><strong id="next-time"></strong><small id="next-action">Нет данных</small></div>
</div>
<div class="schedule-buttons">
<button id="auto-button" class="schedule-button auto" type="button"><svg class="ui-icon action-symbol" aria-hidden="true"><use href="/ui/static/assets/icons.svg#auto"></use></svg><span><strong>Вернуться в AUTO</strong><small>Отменить ручной режим</small></span></button>
<button id="pause-button" class="schedule-button pause" type="button"><svg class="ui-icon action-symbol" aria-hidden="true"><use href="/ui/static/assets/icons.svg#pause"></use></svg><span><strong>Пауза расписания</strong><small>Временно приостановить</small></span></button>
</div>
</div>
<div id="mode-banner" class="mode-banner warn"><span id="mode-title">Загрузка состояния</span><small id="mode-detail">Пожалуйста, подождите</small></div>
</section>
<section class="content-section glass-section extras-section" aria-labelledby="extras-title">
<div class="section-title"><div><svg class="ui-icon section-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#settings"></use></svg><h2 id="extras-title">Дополнительно</h2></div></div>
<div class="extras-grid">
<article class="extra-card glass-tile"><svg class="ui-icon extra-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#home"></use></svg><span><small>На улице</small><strong><span id="outside-temp"></span>°</strong></span></article>
<article class="extra-card mode-card glass-tile">
<svg class="ui-icon extra-icon wind" aria-hidden="true"><use href="/ui/static/assets/icons.svg#wind"></use></svg><span><small>Режим работы</small><strong id="mode-name">Приточная вентиляция</strong><em>Подача и очистка воздуха</em></span>
<div class="segments" role="group" aria-label="Режим забора воздуха"><button id="mode-outside" class="segment" type="button">С улицы</button><button id="mode-recirculation" class="segment" type="button">Рециркуляция</button></div>
</article>
</div>
<div class="micro-settings">
<span>Модель <strong id="model"></strong></span><span>Батарея Qingping <strong><span id="battery"></span>%</strong></span>
<button id="sound-toggle" class="micro-toggle" type="button" aria-pressed="false">Звук OFF</button><button id="light-toggle" class="micro-toggle" type="button" aria-pressed="false">Свет OFF</button>
</div>
</section>
</div>
<span id="demo-note" class="demo-note" hidden>Демонстрационный режим — команды не отправляются устройству</span>
</main>
<dialog id="schedule-dialog" class="schedule-dialog" aria-labelledby="schedule-dialog-title">
<form id="schedule-form" class="schedule-editor">
<header class="editor-header">
<span class="editor-heading"><svg class="ui-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#clock"></use></svg><span><h2 id="schedule-dialog-title">Редактор расписания</h2><small>Изменения сохраняются в config/schedule.yaml</small></span></span>
<button id="schedule-close-button" class="editor-close" type="button" aria-label="Закрыть редактор">×</button>
</header>
<div class="editor-scroll">
<label class="schedule-enabled"><input id="schedule-enabled" type="checkbox"><span><strong>Расписание включено</strong><small>Если выключить, автоматические точки применяться не будут</small></span></label>
<section class="editor-section" aria-labelledby="days-title">
<div class="editor-section-title"><span><h3 id="days-title">Дни недели</h3><small>Назначьте каждому дню один из шаблонов</small></span></div>
<div id="day-assignments" class="day-assignments"></div>
</section>
<section class="editor-section" aria-labelledby="points-title">
<div class="editor-section-title template-toolbar">
<span><h3 id="points-title">Точки расписания</h3><small id="template-usage">Выберите шаблон</small></span>
<label>Шаблон<select id="template-select"></select></label>
<span class="template-actions"><button id="duplicate-template" type="button">Дублировать</button><button id="delete-template" type="button">Удалить</button></span>
</div>
<div id="schedule-points" class="schedule-points"></div>
<button id="add-schedule-point" class="add-schedule-point" type="button">+ Добавить точку</button>
</section>
</div>
<footer class="editor-footer">
<span id="schedule-editor-status" class="editor-status" role="status"></span>
<button id="schedule-cancel-button" class="editor-button secondary" type="button">Отмена</button>
<button id="schedule-save-button" class="editor-button primary" type="submit">Сохранить расписание</button>
</footer>
</form>
</dialog>
<script type="module" src="/ui/static/js/panel.js"></script>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Tion 4S — виджет</title>
<link rel="stylesheet" href="/ui/static/css/base.css">
<link rel="stylesheet" href="/ui/static/css/widget.css">
</head>
<body>
<div class="ambient" aria-hidden="true"></div>
<main class="widget" aria-live="polite">
<header class="widget-header">
<div class="identity">
<span class="brand-icon" aria-hidden="true">
<svg class="ui-icon"><use href="/ui/static/assets/icons.svg#wind"></use></svg>
</span>
<span class="identity-copy">
<span class="title-row"><h1>Tion 4S</h1><span id="connection" class="pill muted">Загрузка</span></span>
<small>Приточная вентиляция</small>
</span>
</div>
<button id="theme-button" class="theme-button" type="button" title="Сменить тему">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.64 5.64l1.42 1.42m9.88 9.88 1.42 1.42m0-12.72-1.42 1.42M7.06 16.94l-1.42 1.42" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/></svg>
</button>
<div class="status-row">
<span id="mode-pill" class="pill muted"></span>
<span id="mode-detail" class="mode-detail">Загрузка состояния</span>
</div>
</header>
<section class="widget-tile control-card speed-card" aria-labelledby="speed-title">
<div class="card-title">
<svg class="ui-icon feature-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#fan"></use></svg>
<span><h2 id="speed-title">Скорость вентиляции</h2><p id="speed-note">Ступень — из 6</p></span>
</div>
<div class="hero-control">
<button class="hero-step" data-step="speed" data-delta="-1" type="button" aria-label="Уменьшить скорость"></button>
<strong id="speed" class="hero-value value skeleton">0</strong>
<button class="hero-step primary" data-step="speed" data-delta="1" type="button" aria-label="Увеличить скорость">+</button>
</div>
<div id="speed-segments" class="speed-segments" role="group" aria-label="Выбор скорости">
<button type="button" data-speed="1" aria-label="Скорость 1"></button>
<button type="button" data-speed="2" aria-label="Скорость 2"></button>
<button type="button" data-speed="3" aria-label="Скорость 3"></button>
<button type="button" data-speed="4" aria-label="Скорость 4"></button>
<button type="button" data-speed="5" aria-label="Скорость 5"></button>
<button type="button" data-speed="6" aria-label="Скорость 6"></button>
</div>
<div class="scale-labels" aria-hidden="true"><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span></div>
</section>
<section class="widget-tile control-card temperature-card" aria-labelledby="temperature-title">
<div class="card-title">
<svg class="ui-icon feature-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#thermometer"></use></svg>
<span><h2 id="temperature-title">Целевая температура</h2><p id="heating-note">Желаемая температура воздуха</p></span>
</div>
<div class="hero-control">
<button class="hero-step" data-step="temperature" data-delta="-1" type="button" aria-label="Уменьшить температуру"></button>
<strong class="hero-value value"><span id="target-temp" class="skeleton">00</span>°</strong>
<button class="hero-step primary" data-step="temperature" data-delta="1" type="button" aria-label="Увеличить температуру">+</button>
</div>
<input id="temperature-range" class="temperature-range" type="range" min="5" max="30" step="1" value="21" aria-label="Целевая температура">
<div class="temperature-labels" aria-hidden="true"><span></span><span>15°</span><span>20°</span><span>25°</span><span>30°</span></div>
</section>
<section class="switch-grid" aria-label="Питание и обогрев">
<article class="widget-tile switch-card power-card">
<div class="switch-title">
<svg class="ui-icon switch-icon green" aria-hidden="true"><use href="/ui/static/assets/icons.svg#power"></use></svg>
<span><h2>Питание</h2><small id="power-detail">Устройство выключено</small></span>
</div>
<button id="power-toggle" class="glass-switch green" type="button" aria-pressed="false">OFF</button>
</article>
<article class="widget-tile switch-card heater-card">
<div class="switch-title">
<svg class="ui-icon switch-icon orange" aria-hidden="true"><use href="/ui/static/assets/icons.svg#heat"></use></svg>
<span><h2>Обогрев</h2><small id="heater-detail">Выключен</small></span>
</div>
<button id="heater-toggle" class="glass-switch orange" type="button" aria-pressed="false">OFF</button>
</article>
</section>
<section class="widget-tile info-card air-card" data-air-tone="muted" aria-labelledby="air-title">
<header class="section-title">
<span><svg class="ui-icon section-icon leaf" aria-hidden="true"><use href="/ui/static/assets/icons.svg#leaf"></use></svg><h2 id="air-title">Воздух в комнате</h2></span>
<button id="air-link" class="open-section" type="button" aria-label="Открыть показатели воздуха в панели"></button>
</header>
<div class="co2-summary">
<span class="co2-label">CO₂</span>
<strong><span id="co2" class="value"></span> <small>ppm</small></strong>
<span id="air-label" class="quality-chip muted">Нет данных</span>
</div>
<div class="co2-meter" aria-hidden="true"><i id="co2-progress"></i></div>
<div class="meter-labels" aria-hidden="true"><span>400</span><span>800</span><span>1200</span><span>2000</span></div>
<div class="mini-metrics">
<div class="mini-metric">
<svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#thermometer"></use></svg>
<span><small>Температура</small><strong><span id="room-temp" class="value"></span>°</strong></span>
</div>
<div class="mini-metric">
<svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#drop"></use></svg>
<span><small>Влажность</small><strong><span id="humidity" class="value"></span>%</strong></span>
</div>
</div>
</section>
<section class="widget-tile info-card schedule-card" aria-labelledby="schedule-title">
<header class="section-title">
<span><svg class="ui-icon section-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#clock"></use></svg><h2 id="schedule-title">Расписание</h2></span>
<button id="schedule-link" class="open-section" type="button" aria-label="Открыть расписание в панели"></button>
</header>
<div class="schedule-times">
<span><small>Сейчас</small><strong id="current-time" class="value"></strong></span>
<i aria-hidden="true"></i>
<span><small>Следующая точка</small><strong id="next-time" class="value"></strong></span>
</div>
</section>
<button id="open-panel" class="open-panel" type="button">
<svg class="ui-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#wind"></use></svg>
<span>Открыть полную панель</span><b aria-hidden="true"></b>
</button>
<span id="demo-note" class="demo-note" hidden>Демонстрационный режим — команды не отправляются устройству</span>
</main>
<script type="module" src="/ui/static/js/widget.js"></script>
</body>
</html>