Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd1b3976d | ||
|
|
e55d7a818e | ||
|
|
3754bbc380 | ||
|
|
a000fb6c44 | ||
|
|
be4c2c403c | ||
|
|
e636bc53ca | ||
|
|
05539d91ed | ||
|
|
fdb798a430 | ||
|
|
33cdb205b3 | ||
|
|
83d2eb619c | ||
|
|
8069ab65f2 | ||
|
|
455d191ea1 | ||
|
|
8f749da14c | ||
|
|
0172b6bcf2 | ||
|
|
65cf547c6c | ||
|
|
3cd71ba5ff | ||
|
|
741be479a6 | ||
|
|
958691a481 | ||
|
|
a5c9d07db5 | ||
|
|
c22e8bc71d | ||
|
|
b02d18d283 | ||
|
|
c6d92c10ee | ||
|
|
bff8efb85b | ||
|
|
f98cc2d6a3 | ||
|
|
cdec9b0996 | ||
|
|
7dbfd8b069 | ||
|
|
a3dafb4f59 | ||
|
|
dec14b1d12 | ||
|
|
4c665d9108 | ||
|
|
419b8efe33 | ||
|
|
fdae9dec72 | ||
|
|
5cc125db3f | ||
|
|
9241da63c3 |
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.14 (ClimatController)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
Generated
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<modules>
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/TionController.iml" filepath="$PROJECT_DIR$/.idea/TionController.iml" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/ClimatController.iml" filepath="$PROJECT_DIR$/.idea/ClimatController.iml" />
|
||||||
</modules>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[version]
|
||||||
|
major = 1
|
||||||
|
minor = 3
|
||||||
|
patch = 0
|
||||||
|
|
||||||
|
[build]
|
||||||
|
date = "2026-09-20"
|
||||||
|
time = "14:17:42"
|
||||||
+139
-1
@@ -1,11 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import shutil
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Literal, Annotated
|
from typing import Literal, Annotated
|
||||||
|
import yaml
|
||||||
from fastapi import FastAPI, Request, HTTPException, Path
|
from fastapi import FastAPI, Request, HTTPException, Path
|
||||||
from fastapi.exceptions import RequestValidationError
|
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 pathlib import Path as FilePath
|
||||||
from app.qingping.service import QingpingService
|
from app.qingping.service import QingpingService
|
||||||
|
from app.metrics import PrometheusMetricsService
|
||||||
|
|
||||||
from app.my_dataclasses import (
|
from app.my_dataclasses import (
|
||||||
TION_MAC,
|
TION_MAC,
|
||||||
@@ -89,6 +93,8 @@ schedule_load_error: str | None = None
|
|||||||
auto_controller: AutoController | None = None
|
auto_controller: AutoController | None = None
|
||||||
auto_load_error: str | None = None
|
auto_load_error: str | None = None
|
||||||
|
|
||||||
|
metrics_service: PrometheusMetricsService | None = None
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Application lifecycle
|
# Application lifecycle
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -99,6 +105,7 @@ async def lifespan(app: FastAPI):
|
|||||||
global schedule_load_error
|
global schedule_load_error
|
||||||
global auto_controller
|
global auto_controller
|
||||||
global auto_load_error
|
global auto_load_error
|
||||||
|
global metrics_service
|
||||||
|
|
||||||
await service.start()
|
await service.start()
|
||||||
await qingping_service.start()
|
await qingping_service.start()
|
||||||
@@ -164,9 +171,32 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
schedule_service = None
|
schedule_service = None
|
||||||
schedule_load_error = f"{type(exc).__name__}: {exc}"
|
schedule_load_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
metrics_service = PrometheusMetricsService(
|
||||||
|
tion_service=service,
|
||||||
|
qingping_service=qingping_service,
|
||||||
|
schedule_service=schedule_service,
|
||||||
|
auto_controller=auto_controller,
|
||||||
|
output_path=(
|
||||||
|
"/var/lib/prometheus/"
|
||||||
|
"node-exporter/"
|
||||||
|
"climatcontroller.prom"
|
||||||
|
),
|
||||||
|
interval=5.0,
|
||||||
|
)
|
||||||
|
await metrics_service.start()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception("Could not start Prometheus metrics service")
|
||||||
|
metrics_service = None
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
if metrics_service is not None:
|
||||||
|
await metrics_service.stop()
|
||||||
|
|
||||||
if schedule_service is not None:
|
if schedule_service is not None:
|
||||||
await schedule_service.stop()
|
await schedule_service.stop()
|
||||||
|
|
||||||
@@ -184,6 +214,34 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
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)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_exception_handler(
|
async def validation_exception_handler(
|
||||||
@@ -724,6 +782,86 @@ async def set_light(
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Переход в auto
|
# Переход в 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")
|
@app.post("/api/schedule/override/clear")
|
||||||
async def clear_schedule_override():
|
async def clear_schedule_override():
|
||||||
if schedule_service is None:
|
if schedule_service is None:
|
||||||
|
|||||||
+6
-7
@@ -23,6 +23,12 @@ class Co2Config:
|
|||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@dataclass(
|
||||||
|
frozen=True,
|
||||||
|
slots=True,
|
||||||
|
)
|
||||||
|
class TemperatureConfig:
|
||||||
|
hysteresis: float
|
||||||
|
|
||||||
@dataclass(
|
@dataclass(
|
||||||
frozen=True,
|
frozen=True,
|
||||||
@@ -34,13 +40,6 @@ class AutoConfig:
|
|||||||
co2: Co2Config
|
co2: Co2Config
|
||||||
temperature: TemperatureConfig
|
temperature: TemperatureConfig
|
||||||
|
|
||||||
@dataclass(
|
|
||||||
frozen=True,
|
|
||||||
slots=True,
|
|
||||||
)
|
|
||||||
class TemperatureConfig:
|
|
||||||
hysteresis: float
|
|
||||||
|
|
||||||
|
|
||||||
def _require_dict(name: str, value: Any) -> dict:
|
def _require_dict(name: str, value: Any) -> dict:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from .service import PrometheusMetricsService
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusMetricsService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tion_service,
|
||||||
|
qingping_service,
|
||||||
|
schedule_service=None,
|
||||||
|
auto_controller=None,
|
||||||
|
output_path: str | Path = (
|
||||||
|
"/var/lib/prometheus/node-exporter/"
|
||||||
|
"climatcontroller.prom"
|
||||||
|
),
|
||||||
|
interval: float = 5.0,
|
||||||
|
):
|
||||||
|
self._tion = tion_service
|
||||||
|
self._qingping = qingping_service
|
||||||
|
self._schedule = schedule_service
|
||||||
|
self._auto = auto_controller
|
||||||
|
|
||||||
|
self._output_path = Path(output_path)
|
||||||
|
self._interval = interval
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._task = None
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
await self._write_metrics()
|
||||||
|
|
||||||
|
self._task = asyncio.create_task(
|
||||||
|
self._loop()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
|
||||||
|
await asyncio.sleep(
|
||||||
|
self._interval
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._write_metrics()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
logger.exception("Prometheus metrics update failed")
|
||||||
|
|
||||||
|
async def _write_metrics(self) -> None:
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Время последнего успешного обновления файла.
|
||||||
|
# Позволяет определить протухшие метрики.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_metrics_timestamp_seconds",
|
||||||
|
time.time(),
|
||||||
|
"Unix timestamp of the last "
|
||||||
|
"ClimatController metrics update",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Tion
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_tion_online",
|
||||||
|
self._tion.online,
|
||||||
|
"Tion connection state",
|
||||||
|
)
|
||||||
|
|
||||||
|
tion_state = self._tion.state
|
||||||
|
|
||||||
|
if tion_state is not None:
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_tion_power",
|
||||||
|
tion_state.power,
|
||||||
|
"Tion power state",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_tion_heater",
|
||||||
|
tion_state.heater,
|
||||||
|
"Tion heater enabled state",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_tion_heating",
|
||||||
|
tion_state.heating,
|
||||||
|
"Tion active heating state",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_tion_fan_speed",
|
||||||
|
tion_state.fan_speed,
|
||||||
|
"Tion fan speed",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_tion_in_temperature_celsius",
|
||||||
|
tion_state.in_temp,
|
||||||
|
"Tion inlet temperature",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_tion_out_temperature_celsius",
|
||||||
|
tion_state.out_temp,
|
||||||
|
"Tion outside temperature",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_tion_target_temperature_celsius",
|
||||||
|
tion_state.target_temp,
|
||||||
|
"Tion target temperature",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Qingping
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_qingping_online",
|
||||||
|
self._qingping.online,
|
||||||
|
"Qingping online state",
|
||||||
|
)
|
||||||
|
|
||||||
|
qingping_state = self._qingping.state
|
||||||
|
|
||||||
|
if qingping_state is not None:
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_qingping_mqtt_connected",
|
||||||
|
qingping_state.mqtt_connected,
|
||||||
|
"Qingping MQTT connection state",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_qingping_temperature_celsius",
|
||||||
|
qingping_state.temperature,
|
||||||
|
"Qingping temperature",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_qingping_humidity_percent",
|
||||||
|
qingping_state.humidity,
|
||||||
|
"Qingping relative humidity",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_qingping_co2_ppm",
|
||||||
|
qingping_state.co2,
|
||||||
|
"Qingping CO2 concentration",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_qingping_pm25_ug_m3",
|
||||||
|
qingping_state.pm25,
|
||||||
|
"Qingping PM2.5 concentration",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_qingping_pm10_ug_m3",
|
||||||
|
qingping_state.pm10,
|
||||||
|
"Qingping PM10 concentration",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_qingping_battery_percent",
|
||||||
|
qingping_state.battery,
|
||||||
|
"Qingping battery level",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Schedule / AUTO
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if self._schedule is not None:
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_schedule_paused",
|
||||||
|
self._schedule.paused,
|
||||||
|
"Schedule manual pause state",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolution = (
|
||||||
|
self._schedule.resolve()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_gauge(
|
||||||
|
lines,
|
||||||
|
"climat_auto_active",
|
||||||
|
resolution.auto_active,
|
||||||
|
"AUTO schedule state",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Schedule metrics resolve failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Atomic write
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
self._output_path.parent.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path = (
|
||||||
|
self._output_path.with_suffix(
|
||||||
|
self._output_path.suffix
|
||||||
|
+ ".tmp"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path.write_text(
|
||||||
|
"\n".join(lines) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path.replace(
|
||||||
|
self._output_path
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_gauge(
|
||||||
|
lines: list[str],
|
||||||
|
name: str,
|
||||||
|
value,
|
||||||
|
help_text: str,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(value, bool):
|
||||||
|
value = 1 if value else 0
|
||||||
|
|
||||||
|
lines.append(f"# HELP {name} {help_text}")
|
||||||
|
|
||||||
|
lines.append(f"# TYPE {name} gauge")
|
||||||
|
|
||||||
|
lines.append(f"{name} {value}")
|
||||||
@@ -74,3 +74,4 @@ days:
|
|||||||
|
|
||||||
sat: weekend
|
sat: weekend
|
||||||
sun: weekend
|
sun: weekend
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
# Это пример Python скрипта.
|
import argparse
|
||||||
|
|
||||||
# Нажмите Shift+F10 для выполнения или замените его своим кодом.
|
import uvicorn
|
||||||
# Нажмите Двойное нажатие Shift для поиска везде: классы, файлы, окна инструментов, действия и настройки.
|
|
||||||
|
|
||||||
|
|
||||||
def print_hi(name):
|
def parse_args() -> argparse.Namespace:
|
||||||
# Используйте точку останова в строке кода ниже для отладки скрипта.
|
parser = argparse.ArgumentParser(description="Tion Controller web server")
|
||||||
print(f'Hi, {name}') # Нажмите Ctrl+F8 для переключения точки останова.
|
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__":
|
||||||
if __name__ == '__main__':
|
args = parse_args()
|
||||||
print_hi('PyCharm')
|
uvicorn.run(
|
||||||
|
"app.api:app",
|
||||||
# Справка PyCharm доступна на https://www.jetbrains.com/help/pycharm/
|
host=args.host,
|
||||||
|
port=args.port,
|
||||||
|
reload=args.reload,
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ fastapi
|
|||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
PyYAML
|
PyYAML
|
||||||
paho-mqtt>=2.1,<3
|
paho-mqtt>=2.1,<3
|
||||||
paho-mqtt
|
|
||||||
@@ -105,6 +105,27 @@ class ScheduleService:
|
|||||||
def config(self) -> ScheduleConfig:
|
def config(self) -> ScheduleConfig:
|
||||||
return self._config
|
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:
|
def _load_runtime_state(self) -> None:
|
||||||
|
|
||||||
if self._state_path is None:
|
if self._state_path is None:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 |
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
@@ -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
@@ -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>5°</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
@@ -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>5°</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>
|
||||||
Reference in New Issue
Block a user