From 08fd69c7862fd17f97c5db9647e9f541715bfe74 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 00:58:18 +0000 Subject: [PATCH 1/8] Add Yale lock and UniFi camera web dashboard Build a FastAPI home dashboard that monitors Yale lock open/close and lock/unlock status, controls the lock remotely, and displays live UniFi Protect camera snapshots. Dependencies are managed with uv. Co-authored-by: Nathan Faggian --- .env.example | 28 +++ .gitignore | 1 + Makefile | 4 + README.md | 43 ++++ pyproject.toml | 17 +- src/yale_lock/__init__.py | 5 + src/yale_lock/__main__.py | 23 +++ src/yale_lock/config.py | 84 ++++++++ src/yale_lock/models.py | 153 ++++++++++++++ src/yale_lock/server.py | 226 +++++++++++++++++++++ src/yale_lock/static/app.js | 284 ++++++++++++++++++++++++++ src/yale_lock/static/styles.css | 245 ++++++++++++++++++++++ src/yale_lock/templates/index.html | 111 ++++++++++ src/yale_lock/unifi_client.py | 143 +++++++++++++ src/yale_lock/yale_client.py | 313 +++++++++++++++++++++++++++++ tests/test_yale_lock.py | 80 ++++++++ 16 files changed, 1759 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 src/yale_lock/__init__.py create mode 100644 src/yale_lock/__main__.py create mode 100644 src/yale_lock/config.py create mode 100644 src/yale_lock/models.py create mode 100644 src/yale_lock/server.py create mode 100644 src/yale_lock/static/app.js create mode 100644 src/yale_lock/static/styles.css create mode 100644 src/yale_lock/templates/index.html create mode 100644 src/yale_lock/unifi_client.py create mode 100644 src/yale_lock/yale_client.py create mode 100644 tests/test_yale_lock.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89037b3 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Yale lock (Yale Access / August app) +YALE_BRAND=yale_access +YALE_LOGIN_METHOD=email +YALE_USERNAME=you@example.com +YALE_PASSWORD=your-yale-password +# Optional: lock device ID (defaults to first lock) +# YALE_LOCK_ID= +YALE_AUTH_CACHE_FILE=.yale_auth_cache +YALE_POLL_INTERVAL_SECONDS=15 +YALE_ACTIVITY_LIMIT=25 + +# UniFi Protect (NVR / Dream Machine) +UNIFI_HOST=192.168.1.1 +UNIFI_PORT=443 +UNIFI_USERNAME=your-unifi-user +UNIFI_PASSWORD=your-unifi-password +# Optional API key for public API features +# UNIFI_API_KEY= +UNIFI_VERIFY_SSL=false +# Optional: default camera ID (defaults to first camera) +# UNIFI_CAMERA_ID= +UNIFI_SNAPSHOT_WIDTH=1280 +UNIFI_SNAPSHOT_HEIGHT=720 +UNIFI_SNAPSHOT_REFRESH_SECONDS=2 + +# Web dashboard +DASHBOARD_HOST=127.0.0.1 +DASHBOARD_PORT=8080 diff --git a/.gitignore b/.gitignore index 2cdfded..2becd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ ml_agent/__pycache__/__init__.cpython-313.pyc uv.lock ml_agent/__pycache__/__init__.cpython-313.pyc ml_agent/tools/__pycache__/google_docs_tool.cpython-313.pyc +.yale_auth_cache diff --git a/Makefile b/Makefile index 455dbd0..d529e15 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,10 @@ test: ## Run all tests --cov-report=term-missing \ --junitxml=junit.xml +.PHONY: dashboard +dashboard: ## Run Yale lock + UniFi camera web dashboard + @uv run home-dashboard + .PHONY: web web: ## Run the ADK web demo server @uv run adk web --reload src/agents/ diff --git a/README.md b/README.md index 6f250e5..cbdf568 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,45 @@ make prefect-server # Start Prefect server and serve flows make prefect-flows # Serve flows (server must be running) make test # Run tests make check # Lint and type check +make dashboard # Run Yale lock + UniFi camera dashboard ``` +## Home Dashboard (Yale Lock + UniFi Camera) + +A web dashboard for monitoring and controlling a Yale smart lock and viewing UniFi Protect camera snapshots. + +### Setup + +```bash +# Install dependencies with uv +make install + +# Copy and edit environment variables +cp .env.example .env +``` + +Configure `.env` with your Yale and UniFi credentials (see `.env.example` for all options). + +### Run + +```bash +make dashboard +``` + +Open `http://127.0.0.1:8080` in your browser. + +### Features + +- **Yale lock**: live lock/door status, recent activity feed, lock/unlock controls +- **UniFi camera**: camera selector and auto-refreshing snapshot feed +- **Real-time updates**: Server-Sent Events push status changes to the browser + +### Requirements + +- Yale lock connected via Yale Access / August app with Wi-Fi bridge +- UniFi Protect NVR (Cloud Key+, Dream Machine, etc.) on your network +- Python 3.13+ managed with [uv](https://docs.astral.sh/uv/) + ## Project Structure ``` @@ -61,6 +98,12 @@ src/ │ ├── agent.py # Agent definition │ └── tools/ │ └── google_docs_tool.py # Google Docs integration +├── yale_lock/ # Yale lock + UniFi camera dashboard +│ ├── server.py # FastAPI application +│ ├── yale_client.py # Yale lock integration +│ ├── unifi_client.py # UniFi Protect camera integration +│ ├── static/ # Web UI assets +│ └── templates/ # HTML templates └── workflows/ ├── pipeline.py # Main Prefect workflow ├── serve.py # Flow serving entry point diff --git a/pyproject.toml b/pyproject.toml index 4a955ba..5f07dc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,11 @@ dependencies = [ "google-api-python-client>=2.100.0", "prefect>=3.0.0", "markdown-it-py>=3.0.0", + "yalexs>=9.2.10", + "aiofiles>=25.1.0", + "fastapi>=0.139.0", + "pydantic-settings>=2.14.2", + "uiprotect>=15.3.0", ] [build-system] @@ -57,6 +62,10 @@ warn_unused_ignores = true show_error_codes = true ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "yale_lock.*" +disable_error_code = ["no-any-unimported"] + [tool.basedpyright] reportAny = false reportExplicitAny = false @@ -79,4 +88,10 @@ source = ["src"] omit = ["server.py"] [tool.deptry.per_rule_ignores] -DEP002 = ["uvicorn"] \ No newline at end of file +DEP002 = ["uvicorn"] + +[project.scripts] +home-dashboard = "yale_lock.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/yale_lock/__init__.py b/src/yale_lock/__init__.py new file mode 100644 index 0000000..cb5f2f4 --- /dev/null +++ b/src/yale_lock/__init__.py @@ -0,0 +1,5 @@ +"""Yale smart lock web dashboard.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/src/yale_lock/__main__.py b/src/yale_lock/__main__.py new file mode 100644 index 0000000..17efe9f --- /dev/null +++ b/src/yale_lock/__main__.py @@ -0,0 +1,23 @@ +"""CLI entry point for the home dashboard.""" + +from __future__ import annotations + +import uvicorn + +from yale_lock.config import Settings +from yale_lock.server import create_app + + +def main() -> None: + settings = Settings() + app = create_app(settings) + uvicorn.run( + app, + host=settings.dashboard_host, + port=settings.dashboard_port, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/src/yale_lock/config.py b/src/yale_lock/config.py new file mode 100644 index 0000000..01c6e55 --- /dev/null +++ b/src/yale_lock/config.py @@ -0,0 +1,84 @@ +"""Application configuration.""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from yalexs.const import Brand + + +class Settings(BaseSettings): + """Home dashboard settings loaded from environment variables.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Yale lock + yale_brand: str = Field(default="yale_access", alias="YALE_BRAND") + yale_login_method: str = Field(default="email", alias="YALE_LOGIN_METHOD") + yale_username: str = Field(default="", alias="YALE_USERNAME") + yale_password: str = Field(default="", alias="YALE_PASSWORD") + yale_lock_id: str = Field( + default="", + alias="YALE_LOCK_ID", + description="Optional lock ID. Uses the first lock when empty.", + ) + yale_auth_cache_file: Path = Field( + default=Path(".yale_auth_cache"), + alias="YALE_AUTH_CACHE_FILE", + ) + yale_poll_interval_seconds: float = Field( + default=15.0, + alias="YALE_POLL_INTERVAL_SECONDS", + ) + yale_activity_limit: int = Field(default=25, alias="YALE_ACTIVITY_LIMIT") + + # UniFi Protect + unifi_host: str = Field(default="", alias="UNIFI_HOST") + unifi_port: int = Field(default=443, alias="UNIFI_PORT") + unifi_username: str = Field(default="", alias="UNIFI_USERNAME") + unifi_password: str = Field(default="", alias="UNIFI_PASSWORD") + unifi_api_key: str = Field(default="", alias="UNIFI_API_KEY") + unifi_verify_ssl: bool = Field(default=False, alias="UNIFI_VERIFY_SSL") + unifi_camera_id: str = Field( + default="", + alias="UNIFI_CAMERA_ID", + description="Optional camera ID. Uses the first camera when empty.", + ) + unifi_snapshot_width: int = Field(default=1280, alias="UNIFI_SNAPSHOT_WIDTH") + unifi_snapshot_height: int = Field(default=720, alias="UNIFI_SNAPSHOT_HEIGHT") + unifi_snapshot_refresh_seconds: float = Field( + default=2.0, + alias="UNIFI_SNAPSHOT_REFRESH_SECONDS", + ) + + # Web server + dashboard_host: str = Field(default="127.0.0.1", alias="DASHBOARD_HOST") + dashboard_port: int = Field(default=8080, alias="DASHBOARD_PORT") + + @field_validator("yale_brand") + @classmethod + def validate_yale_brand(cls, value: str) -> str: + allowed = {brand.value for brand in Brand} + if value not in allowed: + allowed_list = ", ".join(sorted(allowed)) + raise ValueError(f"YALE_BRAND must be one of: {allowed_list}") + return value + + @property + def yale_brand_enum(self) -> Brand: + return Brand(self.yale_brand) + + @property + def yale_configured(self) -> bool: + return bool(self.yale_username and self.yale_password) + + @property + def unifi_configured(self) -> bool: + return bool(self.unifi_host and self.unifi_username and self.unifi_password) diff --git a/src/yale_lock/models.py b/src/yale_lock/models.py new file mode 100644 index 0000000..983c765 --- /dev/null +++ b/src/yale_lock/models.py @@ -0,0 +1,153 @@ +"""Shared data models for the home dashboard.""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field + + +class AuthState(StrEnum): + AUTHENTICATED = "authenticated" + REQUIRES_VALIDATION = "requires_validation" + REQUIRES_AUTHENTICATION = "requires_authentication" + BAD_PASSWORD = "bad_password" + NOT_CONFIGURED = "not_configured" + ERROR = "error" + + +class LockStatusView(StrEnum): + LOCKED = "locked" + UNLOCKED = "unlocked" + LOCKING = "locking" + UNLOCKING = "unlocking" + UNLATCHED = "unlatched" + UNLATCHING = "unlatching" + JAMMED = "jammed" + UNKNOWN = "unknown" + + +class DoorStatusView(StrEnum): + OPEN = "open" + CLOSED = "closed" + UNKNOWN = "unknown" + DISABLED = "disabled" + + +class ActivityView(BaseModel): + id: str + action: str + label: str + device_name: str | None = None + operator: str | None = None + timestamp: datetime + activity_type: str + + +class LockInfo(BaseModel): + lock_id: str + name: str + house_id: str + battery_level: int | None = None + bridge_online: bool = False + doorsense: bool = False + + +class CameraInfo(BaseModel): + camera_id: str + name: str + is_connected: bool = False + is_recording: bool = False + is_motion_detected: bool = False + last_motion: datetime | None = None + + +class CameraStatus(BaseModel): + configured: bool + connected: bool = False + message: str | None = None + cameras: list[CameraInfo] = Field(default_factory=list) + selected_camera_id: str | None = None + + +class StatusSnapshot(BaseModel): + authenticated: bool + auth_state: AuthState + auth_message: str | None = None + lock: LockInfo | None = None + lock_status: LockStatusView = LockStatusView.UNKNOWN + door_status: DoorStatusView = DoorStatusView.UNKNOWN + lock_status_updated_at: datetime | None = None + door_status_updated_at: datetime | None = None + activities: list[ActivityView] = Field(default_factory=list) + camera: CameraStatus = Field(default_factory=lambda: CameraStatus(configured=False)) + updated_at: datetime = Field(default_factory=datetime.now) + + +class OperationResult(BaseModel): + success: bool + message: str + status: StatusSnapshot | None = None + + +class CredentialsRequest(BaseModel): + username: str + password: str + login_method: str = "email" + + +class VerificationRequest(BaseModel): + code: str + + +def activity_label(action: str) -> str: + labels = { + "lock": "Locked", + "unlock": "Unlocked", + "dooropen": "Door opened", + "door_open": "Door opened", + "doorclosed": "Door closed", + "door_close": "Door closed", + "auto_lock": "Auto-locked", + "manual_lock": "Manually locked", + "manual_unlock": "Manually unlocked", + "remote_lock": "Remotely locked", + "remote_unlock": "Remotely unlocked", + "pin_lock": "Locked with PIN", + "pin_unlock": "Unlocked with PIN", + "jammed": "Lock jammed", + "locking": "Locking", + "unlocking": "Unlocking", + "unlatch": "Unlatched", + "associated_bridge_online": "Bridge online", + "associated_bridge_offline": "Bridge offline", + } + return labels.get(action, action.replace("_", " ").title()) + + +def serialize_activity(activity: Any) -> ActivityView: + operator: str | None = None + if hasattr(activity, "operated_by") and activity.operated_by: + operator = str(activity.operated_by) + elif activity.calling_user: + first = activity.calling_user.get("FirstName", "") + last = activity.calling_user.get("LastName", "") + full_name = f"{first} {last}".strip() + operator = full_name or None + + action = activity.action or "unknown" + activity_id = activity.activity_id or ( + f"{activity.device_id}-{activity.activity_start_time.isoformat()}" + ) + + return ActivityView( + id=activity_id, + action=action, + label=activity_label(action), + device_name=activity.device_name, + operator=operator, + timestamp=activity.activity_start_time, + activity_type=activity.activity_type.value, + ) diff --git a/src/yale_lock/server.py b/src/yale_lock/server.py new file mode 100644 index 0000000..81b9d71 --- /dev/null +++ b/src/yale_lock/server.py @@ -0,0 +1,226 @@ +"""FastAPI application for Yale lock and UniFi camera dashboard.""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from pathlib import Path +from typing import AsyncIterator + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, Response, StreamingResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from yale_lock.config import Settings +from yale_lock.models import ( + AuthState, + CredentialsRequest, + OperationResult, + StatusSnapshot, + VerificationRequest, +) +from yale_lock.unifi_client import UniFiCameraClient +from yale_lock.yale_client import YaleLockClient + +_LOGGER = logging.getLogger(__name__) +APP_DIR = Path(__file__).resolve().parent + + +class DashboardApp: + """Coordinates Yale and UniFi clients for the web dashboard.""" + + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.yale = YaleLockClient(settings) + self.unifi = UniFiCameraClient(settings) + self._status_subscribers: set[asyncio.Queue[StatusSnapshot]] = set() + + async def start(self) -> None: + await self.yale.start() + await self.unifi.start() + + async def stop(self) -> None: + await self.yale.stop() + await self.unifi.stop() + + def build_status(self) -> StatusSnapshot: + return StatusSnapshot( + authenticated=self.yale.is_authenticated, + auth_state=self.yale.auth_state, + auth_message=self.yale.auth_message, + lock=self.yale.get_lock_info(), + lock_status=self.yale.get_lock_status(), + door_status=self.yale.get_door_status(), + lock_status_updated_at=self.yale.get_lock_status_updated_at(), + door_status_updated_at=self.yale.get_door_status_updated_at(), + activities=self.yale.get_activities(), + camera=self.unifi.get_status(), + updated_at=datetime.now(tz=UTC), + ) + + async def refresh_all(self) -> StatusSnapshot: + if self.yale.is_authenticated: + await self.yale.refresh() + await self.unifi.refresh() + status = self.build_status() + await self._broadcast(status) + return status + + async def _broadcast(self, status: StatusSnapshot) -> None: + for queue in list(self._status_subscribers): + try: + queue.put_nowait(status) + except asyncio.QueueFull: + self._status_subscribers.discard(queue) + + def subscribe(self) -> asyncio.Queue[StatusSnapshot]: + queue: asyncio.Queue[StatusSnapshot] = asyncio.Queue(maxsize=8) + self._status_subscribers.add(queue) + return queue + + def unsubscribe(self, queue: asyncio.Queue[StatusSnapshot]) -> None: + self._status_subscribers.discard(queue) + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or Settings() + dashboard = DashboardApp(settings) + templates = Jinja2Templates(directory=str(APP_DIR / "templates")) + + @asynccontextmanager + async def lifespan(_: FastAPI) -> AsyncIterator[None]: + await dashboard.start() + yield + await dashboard.stop() + + app = FastAPI( + title="Home Dashboard", + description="Yale lock and UniFi camera dashboard", + lifespan=lifespan, + ) + app.mount("/static", StaticFiles(directory=str(APP_DIR / "static")), name="static") + app.state.dashboard = dashboard + app.state.templates = templates + app.state.settings = settings + + @app.get("/", response_class=HTMLResponse) + async def index(request: Request) -> HTMLResponse: + status = dashboard.build_status() + return templates.TemplateResponse( + request, + "index.html", + { + "status": status, + "settings": settings, + }, + ) + + @app.get("/api/status", response_model=StatusSnapshot) + async def get_status() -> StatusSnapshot: + return dashboard.build_status() + + @app.post("/api/refresh", response_model=StatusSnapshot) + async def refresh_status() -> StatusSnapshot: + return await dashboard.refresh_all() + + @app.post("/api/lock", response_model=OperationResult) + async def lock_door() -> OperationResult: + try: + await dashboard.yale.lock_door() + status = dashboard.build_status() + await dashboard._broadcast(status) + return OperationResult(success=True, message="Lock command sent.", status=status) + except Exception as exc: + _LOGGER.exception("Lock command failed") + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/unlock", response_model=OperationResult) + async def unlock_door() -> OperationResult: + try: + await dashboard.yale.unlock_door() + status = dashboard.build_status() + await dashboard._broadcast(status) + return OperationResult(success=True, message="Unlock command sent.", status=status) + except Exception as exc: + _LOGGER.exception("Unlock command failed") + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.post("/api/auth/login", response_model=StatusSnapshot) + async def login(payload: CredentialsRequest) -> StatusSnapshot: + state = await dashboard.yale.login( + payload.username, + payload.password, + payload.login_method, + ) + if state == AuthState.REQUIRES_VALIDATION: + await dashboard.yale.send_verification_code() + if dashboard.yale.is_authenticated: + await dashboard.unifi.refresh() + status = dashboard.build_status() + await dashboard._broadcast(status) + return status + + @app.post("/api/auth/verify", response_model=StatusSnapshot) + async def verify(payload: VerificationRequest) -> StatusSnapshot: + await dashboard.yale.validate_verification_code(payload.code) + if dashboard.yale.is_authenticated: + await dashboard.yale.refresh() + status = dashboard.build_status() + await dashboard._broadcast(status) + return status + + @app.post("/api/auth/send-code") + async def send_code() -> dict[str, bool]: + sent = await dashboard.yale.send_verification_code() + return {"sent": sent} + + @app.get("/api/cameras") + async def list_cameras() -> dict[str, object]: + await dashboard.unifi.refresh() + status = dashboard.unifi.get_status() + return status.model_dump() + + @app.post("/api/cameras/{camera_id}/select") + async def select_camera(camera_id: str) -> dict[str, object]: + if not dashboard.unifi.select_camera(camera_id): + raise HTTPException(status_code=404, detail="Camera not found") + status = dashboard.unifi.get_status() + return status.model_dump() + + @app.get("/api/camera/snapshot") + async def camera_snapshot(camera_id: str | None = None) -> Response: + snapshot = await dashboard.unifi.get_snapshot(camera_id) + if snapshot is None: + raise HTTPException(status_code=503, detail="Camera snapshot unavailable") + return Response(content=snapshot, media_type="image/jpeg") + + @app.get("/api/events") + async def event_stream() -> StreamingResponse: + queue = dashboard.subscribe() + + async def generator() -> AsyncIterator[str]: + try: + initial = dashboard.build_status() + yield f"data: {initial.model_dump_json()}\n\n" + while True: + status = await queue.get() + yield f"data: {status.model_dump_json()}\n\n" + finally: + dashboard.unsubscribe(queue) + + return StreamingResponse( + generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + + return app + + +app = create_app() diff --git a/src/yale_lock/static/app.js b/src/yale_lock/static/app.js new file mode 100644 index 0000000..3850486 --- /dev/null +++ b/src/yale_lock/static/app.js @@ -0,0 +1,284 @@ +const config = window.DASHBOARD_CONFIG || {}; +const refreshMs = Math.max(1000, (config.snapshotRefreshSeconds || 2) * 1000); + +const els = { + connectionPill: document.getElementById("connection-pill"), + refreshBtn: document.getElementById("refresh-btn"), + authPanel: document.getElementById("auth-panel"), + authMessage: document.getElementById("auth-message"), + loginForm: document.getElementById("login-form"), + verifyForm: document.getElementById("verify-form"), + loginUsername: document.getElementById("login-username"), + loginPassword: document.getElementById("login-password"), + verifyCode: document.getElementById("verify-code"), + lockName: document.getElementById("lock-name"), + lockStatus: document.getElementById("lock-status"), + doorStatus: document.getElementById("door-status"), + batteryLevel: document.getElementById("battery-level"), + lockStatusCard: document.getElementById("lock-status-card"), + doorStatusCard: document.getElementById("door-status-card"), + bridgeStatus: document.getElementById("bridge-status"), + lockUpdated: document.getElementById("lock-updated"), + lockBtn: document.getElementById("lock-btn"), + unlockBtn: document.getElementById("unlock-btn"), + cameraSelect: document.getElementById("camera-select"), + cameraImage: document.getElementById("camera-image"), + cameraMessage: document.getElementById("camera-message"), + cameraStatus: document.getElementById("camera-status"), + activityList: document.getElementById("activity-list"), +}; + +let selectedCameraId = null; +let snapshotTimer = null; + +function titleCase(value) { + return String(value || "unknown").replaceAll("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function formatTime(value) { + if (!value) return "—"; + return new Date(value).toLocaleString(); +} + +function setPill(text, tone = "neutral") { + els.connectionPill.textContent = text; + els.connectionPill.className = `pill pill-${tone}`; +} + +function applyStatusCard(card, value) { + card.classList.remove("locked", "unlocked", "open", "closed"); + if (value) card.classList.add(value); +} + +async function api(path, options = {}) { + const response = await fetch(path, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + if (!response.ok) { + const detail = await response.text(); + throw new Error(detail || response.statusText); + } + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + return response.json(); + } + return response; +} + +function renderActivities(activities) { + els.activityList.innerHTML = ""; + if (!activities || activities.length === 0) { + els.activityList.innerHTML = '
  • No recent activity
  • '; + return; + } + + for (const activity of activities) { + const item = document.createElement("li"); + item.className = "activity-item"; + item.innerHTML = ` +
    + ${activity.label} + ${activity.operator || activity.device_name || "System"} +
    + ${formatTime(activity.timestamp)} + `; + els.activityList.appendChild(item); + } +} + +function renderCameraOptions(cameras, selectedId) { + els.cameraSelect.innerHTML = ""; + if (!cameras || cameras.length === 0) { + const option = document.createElement("option"); + option.textContent = "No cameras found"; + els.cameraSelect.appendChild(option); + els.cameraSelect.disabled = true; + return; + } + + els.cameraSelect.disabled = false; + for (const camera of cameras) { + const option = document.createElement("option"); + option.value = camera.camera_id; + option.textContent = camera.name; + option.selected = camera.camera_id === selectedId; + els.cameraSelect.appendChild(option); + } +} + +function updateCameraSnapshot() { + if (!selectedCameraId) return; + const url = `/api/camera/snapshot?camera_id=${encodeURIComponent(selectedCameraId)}&t=${Date.now()}`; + els.cameraImage.onload = () => { + els.cameraImage.hidden = false; + els.cameraMessage.classList.add("hidden"); + }; + els.cameraImage.onerror = () => { + els.cameraImage.hidden = true; + els.cameraMessage.textContent = "Unable to load camera snapshot"; + els.cameraMessage.classList.remove("hidden"); + }; + els.cameraImage.src = url; +} + +function startSnapshotLoop() { + if (snapshotTimer) clearInterval(snapshotTimer); + if (!selectedCameraId) return; + updateCameraSnapshot(); + snapshotTimer = setInterval(updateCameraSnapshot, refreshMs); +} + +function renderStatus(status) { + const authState = status.auth_state; + const needsAuth = authState !== "authenticated" && config.yaleConfigured; + + if (needsAuth || authState === "requires_validation") { + els.authPanel.classList.remove("hidden"); + els.authMessage.textContent = status.auth_message || "Sign in to control your Yale lock."; + els.verifyForm.classList.toggle("hidden", authState !== "requires_validation"); + els.loginForm.classList.toggle("hidden", authState === "requires_validation"); + } else { + els.authPanel.classList.add("hidden"); + } + + if (status.authenticated) { + setPill("Yale connected", "success"); + } else if (config.yaleConfigured) { + setPill("Yale auth required", "warning"); + } else { + setPill("Yale not configured", "neutral"); + } + + const lock = status.lock; + els.lockName.textContent = lock ? lock.name : "No lock selected"; + els.lockStatus.textContent = titleCase(status.lock_status); + els.doorStatus.textContent = titleCase(status.door_status); + els.batteryLevel.textContent = lock && lock.battery_level != null ? `${lock.battery_level}%` : "—"; + els.bridgeStatus.textContent = lock ? `Bridge: ${lock.bridge_online ? "Online" : "Offline"}` : "Bridge: —"; + els.lockUpdated.textContent = `Updated: ${formatTime(status.lock_status_updated_at || status.updated_at)}`; + + applyStatusCard(els.lockStatusCard, status.lock_status); + applyStatusCard(els.doorStatusCard, status.door_status); + + const controlsEnabled = status.authenticated && !!lock; + els.lockBtn.disabled = !controlsEnabled; + els.unlockBtn.disabled = !controlsEnabled; + + renderActivities(status.activities); + + const camera = status.camera || {}; + if (!config.unifiConfigured) { + els.cameraMessage.textContent = "Set UNIFI_HOST, UNIFI_USERNAME, and UNIFI_PASSWORD in .env"; + els.cameraMessage.classList.remove("hidden"); + els.cameraImage.hidden = true; + return; + } + + if (!camera.connected) { + els.cameraMessage.textContent = camera.message || "UniFi Protect unavailable"; + els.cameraMessage.classList.remove("hidden"); + els.cameraImage.hidden = true; + } + + renderCameraOptions(camera.cameras || [], camera.selected_camera_id); + selectedCameraId = camera.selected_camera_id || selectedCameraId; + const active = (camera.cameras || []).find((item) => item.camera_id === selectedCameraId); + els.cameraStatus.textContent = active + ? `Status: ${active.is_connected ? "Connected" : "Offline"}${active.is_motion_detected ? " · Motion" : ""}` + : "Status: —"; + + if (camera.connected && selectedCameraId) { + startSnapshotLoop(); + } +} + +async function refreshStatus() { + const status = await api("/api/refresh", { method: "POST" }); + renderStatus(status); +} + +function connectEvents() { + const source = new EventSource("/api/events"); + source.onmessage = (event) => { + renderStatus(JSON.parse(event.data)); + }; + source.onerror = () => { + setPill("Live updates paused", "warning"); + }; +} + +els.refreshBtn.addEventListener("click", () => { + refreshStatus().catch((error) => setPill(error.message, "danger")); +}); + +els.lockBtn.addEventListener("click", async () => { + try { + const result = await api("/api/lock", { method: "POST" }); + if (result.status) renderStatus(result.status); + } catch (error) { + setPill(error.message, "danger"); + } +}); + +els.unlockBtn.addEventListener("click", async () => { + try { + const result = await api("/api/unlock", { method: "POST" }); + if (result.status) renderStatus(result.status); + } catch (error) { + setPill(error.message, "danger"); + } +}); + +els.loginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const status = await api("/api/auth/login", { + method: "POST", + body: JSON.stringify({ + username: els.loginUsername.value, + password: els.loginPassword.value, + login_method: "email", + }), + }); + renderStatus(status); + } catch (error) { + els.authMessage.textContent = error.message; + } +}); + +els.verifyForm.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const status = await api("/api/auth/verify", { + method: "POST", + body: JSON.stringify({ code: els.verifyCode.value }), + }); + renderStatus(status); + } catch (error) { + els.authMessage.textContent = error.message; + } +}); + +els.cameraSelect.addEventListener("change", async () => { + selectedCameraId = els.cameraSelect.value; + try { + await api(`/api/cameras/${encodeURIComponent(selectedCameraId)}/select`, { method: "POST" }); + startSnapshotLoop(); + } catch (error) { + els.cameraMessage.textContent = error.message; + els.cameraMessage.classList.remove("hidden"); + } +}); + +async function init() { + try { + const status = await api("/api/status"); + renderStatus(status); + connectEvents(); + } catch (error) { + setPill(error.message, "danger"); + } +} + +init(); diff --git a/src/yale_lock/static/styles.css b/src/yale_lock/static/styles.css new file mode 100644 index 0000000..de73848 --- /dev/null +++ b/src/yale_lock/static/styles.css @@ -0,0 +1,245 @@ +:root { + color-scheme: dark; + --bg: #0b1020; + --panel: #121933; + --panel-border: #243056; + --text: #edf2ff; + --muted: #9aa7c7; + --primary: #4f8cff; + --primary-hover: #6ea0ff; + --success: #3ecf8e; + --warning: #ffb020; + --danger: #ff6b6b; + --neutral: #5b6b8c; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: radial-gradient(circle at top, #172040 0%, var(--bg) 45%); + color: var(--text); + min-height: 100vh; +} + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 24px; +} + +.header, +.panel-header, +.status-row, +.actions, +.meta, +.header-actions { + display: flex; + gap: 12px; +} + +.header { + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.eyebrow { + margin: 0 0 4px; + color: var(--muted); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +h1, h2 { + margin: 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.panel { + background: rgba(18, 25, 51, 0.92); + border: 1px solid var(--panel-border); + border-radius: 18px; + padding: 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); +} + +.activity-panel { + grid-column: 1 / -1; +} + +.panel-header { + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.status-row { + margin-bottom: 16px; +} + +.status-card { + flex: 1; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 14px; +} + +.label { + margin: 0 0 6px; + color: var(--muted); + font-size: 0.85rem; +} + +.value { + margin: 0; + font-size: 1.4rem; + font-weight: 700; +} + +.muted { + color: var(--muted); +} + +.btn, +.select, +input { + font: inherit; +} + +.btn { + border: 0; + border-radius: 12px; + padding: 10px 16px; + cursor: pointer; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-hover); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.select, +input { + width: 100%; + border: 1px solid var(--panel-border); + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + color: var(--text); + padding: 10px 12px; +} + +.select { + width: auto; + min-width: 180px; +} + +.stack { + display: grid; + gap: 12px; +} + +label { + display: grid; + gap: 6px; +} + +.pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 6px 12px; + font-size: 0.85rem; + font-weight: 600; +} + +.pill-success { background: rgba(62, 207, 142, 0.15); color: var(--success); } +.pill-warning { background: rgba(255, 176, 32, 0.15); color: var(--warning); } +.pill-danger { background: rgba(255, 107, 107, 0.15); color: var(--danger); } +.pill-neutral { background: rgba(91, 107, 140, 0.2); color: var(--muted); } + +.status-card.locked { border-color: rgba(62, 207, 142, 0.35); } +.status-card.unlocked { border-color: rgba(255, 176, 32, 0.35); } +.status-card.open { border-color: rgba(255, 107, 107, 0.35); } +.status-card.closed { border-color: rgba(62, 207, 142, 0.35); } + +.camera-frame { + aspect-ratio: 16 / 9; + background: #05070f; + border-radius: 14px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.camera-frame img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.camera-message { + margin-bottom: 12px; +} + +.activity-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; +} + +.activity-item { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.activity-item strong { + display: block; + margin-bottom: 4px; +} + +.hidden { + display: none !important; +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: 1fr; + } + + .header { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/yale_lock/templates/index.html b/src/yale_lock/templates/index.html new file mode 100644 index 0000000..f02e92a --- /dev/null +++ b/src/yale_lock/templates/index.html @@ -0,0 +1,111 @@ + + + + + + Home Dashboard + + + +
    +
    +
    +

    Home Dashboard

    +

    Yale Lock & UniFi Camera

    +
    +
    + Connecting… + +
    +
    + + + +
    +
    +
    +

    Yale Lock

    + +
    + +
    +
    +

    Lock

    +

    Unknown

    +
    +
    +

    Door

    +

    Unknown

    +
    +
    +

    Battery

    +

    +
    +
    + +
    + + +
    + +
    +

    Bridge: —

    +

    Updated: —

    +
    +
    + +
    +
    +

    UniFi Camera

    + +
    + +
    Camera feed unavailable
    +
    + +
    +
    +

    Status: —

    +
    +
    + +
    +
    +

    Recent Activity

    +
    +
      +
      +
      +
      + + + + + diff --git a/src/yale_lock/unifi_client.py b/src/yale_lock/unifi_client.py new file mode 100644 index 0000000..5e83da7 --- /dev/null +++ b/src/yale_lock/unifi_client.py @@ -0,0 +1,143 @@ +"""UniFi Protect camera client wrapper.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from uiprotect import ProtectApiClient + +from yale_lock.config import Settings +from yale_lock.models import CameraInfo, CameraStatus + +if TYPE_CHECKING: + from uiprotect.data.devices import Camera + +_LOGGER = logging.getLogger(__name__) + + +class UniFiCameraClient: + """Async wrapper around the uiprotect library.""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._client: ProtectApiClient | None = None + self._connected = False + self._message: str | None = None + self._cameras: list[CameraInfo] = [] + self._selected_camera_id: str | None = None + self._connect_lock = asyncio.Lock() + + @property + def configured(self) -> bool: + return self._settings.unifi_configured + + @property + def connected(self) -> bool: + return self._connected + + async def start(self) -> None: + if not self.configured: + self._message = "Set UNIFI_HOST, UNIFI_USERNAME, and UNIFI_PASSWORD in .env" + return + await self.connect() + + async def stop(self) -> None: + if self._client is not None: + await self._client.close_session() + self._client = None + self._connected = False + + async def connect(self) -> bool: + if not self.configured: + self._message = "UniFi Protect is not configured" + return False + + async with self._connect_lock: + try: + api_key = self._settings.unifi_api_key or None + self._client = ProtectApiClient( + self._settings.unifi_host, + self._settings.unifi_port, + self._settings.unifi_username, + self._settings.unifi_password, + api_key=api_key, + verify_ssl=self._settings.unifi_verify_ssl, + ) + await self._client.update() + self._connected = True + self._message = None + await self._refresh_cameras() + return True + except Exception as exc: + _LOGGER.exception("UniFi Protect connection failed") + self._connected = False + self._message = str(exc) + return False + + async def _refresh_cameras(self) -> None: + if self._client is None or not self._connected: + return + + cameras = list(self._client.bootstrap.cameras.values()) + self._cameras = [self._serialize_camera(camera) for camera in cameras] + + if self._settings.unifi_camera_id: + if any(camera.camera_id == self._settings.unifi_camera_id for camera in self._cameras): + self._selected_camera_id = self._settings.unifi_camera_id + elif self._cameras: + self._selected_camera_id = self._cameras[0].camera_id + elif self._cameras and not self._selected_camera_id: + self._selected_camera_id = self._cameras[0].camera_id + + def _serialize_camera(self, camera: Camera) -> CameraInfo: + return CameraInfo( + camera_id=camera.id, + name=camera.name or "Camera", + is_connected=camera.is_connected, + is_recording=camera.is_recording, + is_motion_detected=camera.is_motion_detected, + last_motion=camera.last_motion, + ) + + def get_status(self) -> CameraStatus: + return CameraStatus( + configured=self.configured, + connected=self._connected, + message=self._message, + cameras=self._cameras, + selected_camera_id=self._selected_camera_id, + ) + + def select_camera(self, camera_id: str) -> bool: + if any(camera.camera_id == camera_id for camera in self._cameras): + self._selected_camera_id = camera_id + return True + return False + + async def get_snapshot(self, camera_id: str | None = None) -> bytes | None: + if self._client is None or not self._connected: + connected = await self.connect() + if not connected: + return None + + assert self._client is not None + target_id = camera_id or self._selected_camera_id + if not target_id: + await self._refresh_cameras() + target_id = self._selected_camera_id + if not target_id: + return None + + return await self._client.get_camera_snapshot( + target_id, + width=self._settings.unifi_snapshot_width, + height=self._settings.unifi_snapshot_height, + ) + + async def refresh(self) -> None: + if self._client is None or not self._connected: + await self.connect() + return + await self._refresh_cameras() diff --git a/src/yale_lock/yale_client.py b/src/yale_lock/yale_client.py new file mode 100644 index 0000000..8d1e4ba --- /dev/null +++ b/src/yale_lock/yale_client.py @@ -0,0 +1,313 @@ +"""Yale smart lock client wrapper.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from aiohttp import ClientSession +from yalexs.api_async import ApiAsync +from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync +from yalexs.lock import LockDoorStatus, LockStatus + +from yale_lock.config import Settings +from yale_lock.models import ( + ActivityView, + AuthState, + DoorStatusView, + LockInfo, + LockStatusView, + serialize_activity, +) + +if TYPE_CHECKING: + from yalexs.activity import ActivityTypes + from yalexs.lock import Lock, LockDetail + +_LOGGER = logging.getLogger(__name__) + +_LOCK_STATUS_MAP = { + LockStatus.LOCKED: LockStatusView.LOCKED, + LockStatus.UNLOCKED: LockStatusView.UNLOCKED, + LockStatus.LOCKING: LockStatusView.LOCKING, + LockStatus.UNLOCKING: LockStatusView.UNLOCKING, + LockStatus.UNLATCHED: LockStatusView.UNLATCHED, + LockStatus.UNLATCHING: LockStatusView.UNLATCHING, + LockStatus.JAMMED: LockStatusView.JAMMED, + LockStatus.UNKNOWN: LockStatusView.UNKNOWN, +} + +_DOOR_STATUS_MAP = { + LockDoorStatus.OPEN: DoorStatusView.OPEN, + LockDoorStatus.CLOSED: DoorStatusView.CLOSED, + LockDoorStatus.UNKNOWN: DoorStatusView.UNKNOWN, + LockDoorStatus.DISABLED: DoorStatusView.DISABLED, +} + + +class YaleLockClient: + """Async wrapper around the yalexs library.""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._session: ClientSession | None = None + self._api: ApiAsync | None = None + self._authenticator: AuthenticatorAsync | None = None + self._access_token: str | None = None + self._auth_state = AuthState.NOT_CONFIGURED + self._auth_message: str | None = None + self._lock: Lock | None = None + self._lock_detail: LockDetail | None = None + self._activities: list[ActivityView] = [] + self._lock_status = LockStatusView.UNKNOWN + self._door_status = DoorStatusView.UNKNOWN + self._lock_status_updated_at: datetime | None = None + self._door_status_updated_at: datetime | None = None + self._poll_task: asyncio.Task[None] | None = None + self._refresh_lock = asyncio.Lock() + + @property + def auth_state(self) -> AuthState: + return self._auth_state + + @property + def auth_message(self) -> str | None: + return self._auth_message + + @property + def is_authenticated(self) -> bool: + return self._auth_state == AuthState.AUTHENTICATED + + async def start(self) -> None: + if not self._settings.yale_configured: + self._auth_state = AuthState.NOT_CONFIGURED + self._auth_message = "Set YALE_USERNAME and YALE_PASSWORD in .env" + return + + await self._ensure_session() + await self._setup_authenticator() + await self.authenticate() + if self.is_authenticated: + await self.refresh() + self._poll_task = asyncio.create_task(self._poll_loop()) + + async def stop(self) -> None: + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + + if self._session and not self._session.closed: + await self._session.close() + self._session = None + self._api = None + self._authenticator = None + + async def _ensure_session(self) -> None: + if self._session is None or self._session.closed: + self._session = ClientSession() + self._api = ApiAsync( + self._session, + timeout=20, + brand=self._settings.yale_brand_enum, + ) + + async def _setup_authenticator(self) -> None: + if self._api is None: + await self._ensure_session() + assert self._api is not None + + self._authenticator = AuthenticatorAsync( + self._api, + self._settings.yale_login_method, + self._settings.yale_username, + self._settings.yale_password, + access_token_cache_file=str(self._settings.yale_auth_cache_file), + ) + await self._authenticator.async_setup_authentication() + + async def authenticate(self) -> AuthState: + if not self._settings.yale_configured: + self._auth_state = AuthState.NOT_CONFIGURED + return self._auth_state + + try: + await self._ensure_session() + await self._setup_authenticator() + assert self._authenticator is not None + + authentication = await self._authenticator.async_authenticate() + if authentication.state is AuthenticationState.AUTHENTICATED: + self._access_token = authentication.access_token + self._auth_state = AuthState.AUTHENTICATED + self._auth_message = None + elif authentication.state is AuthenticationState.REQUIRES_VALIDATION: + self._auth_state = AuthState.REQUIRES_VALIDATION + self._auth_message = "Enter the verification code sent to your email or phone." + elif authentication.state is AuthenticationState.BAD_PASSWORD: + self._auth_state = AuthState.BAD_PASSWORD + self._auth_message = "Invalid Yale credentials." + else: + self._auth_state = AuthState.REQUIRES_AUTHENTICATION + self._auth_message = "Yale authentication required." + except Exception as exc: + _LOGGER.exception("Yale authentication failed") + self._auth_state = AuthState.ERROR + self._auth_message = str(exc) + + return self._auth_state + + async def send_verification_code(self) -> bool: + if self._authenticator is None: + await self._setup_authenticator() + assert self._authenticator is not None + return bool(await self._authenticator.async_send_verification_code()) + + async def validate_verification_code(self, code: str) -> AuthState: + if self._authenticator is None: + await self._setup_authenticator() + assert self._authenticator is not None + + await self._authenticator.async_validate_verification_code(code) + return await self.authenticate() + + async def login(self, username: str, password: str, login_method: str = "email") -> AuthState: + self._settings.yale_username = username + self._settings.yale_password = password + self._settings.yale_login_method = login_method + self._authenticator = None + state = await self.authenticate() + if self.is_authenticated: + await self.refresh() + if self._poll_task is None: + self._poll_task = asyncio.create_task(self._poll_loop()) + return state + + async def _get_access_token(self) -> str: + if not self._access_token: + await self.authenticate() + if not self._access_token: + raise RuntimeError("Not authenticated with Yale") + return self._access_token + + async def _resolve_lock(self) -> Lock: + api = self._api + if api is None: + raise RuntimeError("Yale API not initialized") + + access_token = await self._get_access_token() + locks = await api.async_get_locks(access_token) + if not locks: + raise RuntimeError("No Yale locks found on this account") + + if self._settings.yale_lock_id: + for lock in locks: + if lock.device_id == self._settings.yale_lock_id: + self._lock = lock + return lock + raise RuntimeError(f"Lock {self._settings.yale_lock_id} not found") + + self._lock = locks[0] + return locks[0] + + async def refresh(self) -> None: + if not self.is_authenticated: + return + + async with self._refresh_lock: + api = self._api + if api is None: + return + + access_token = await self._get_access_token() + lock = await self._resolve_lock() + detail = await api.async_get_lock_detail(access_token, lock) + self._lock_detail = detail + + door_status, lock_status = await api.async_get_lock_door_status( + access_token, + lock.device_id, + lock_status=True, + ) + self._lock_status = _LOCK_STATUS_MAP.get(lock_status, LockStatusView.UNKNOWN) + self._door_status = _DOOR_STATUS_MAP.get(door_status, DoorStatusView.UNKNOWN) + now = datetime.now(tz=UTC) + self._lock_status_updated_at = now + if detail.doorsense: + self._door_status_updated_at = now + + raw_activities: list[ActivityTypes] = await api.async_get_house_activities( + access_token, + lock.house_id, + limit=self._settings.yale_activity_limit, + ) + lock_id = lock.device_id + filtered = [ + activity + for activity in raw_activities + if activity.device_id == lock_id or activity.device_id is None + ] + self._activities = [serialize_activity(activity) for activity in filtered] + + async def lock_door(self) -> None: + api = self._api + if api is None: + raise RuntimeError("Yale API not initialized") + + access_token = await self._get_access_token() + lock = await self._resolve_lock() + await api.async_lock(access_token, lock.device_id) + await self.refresh() + + async def unlock_door(self) -> None: + api = self._api + if api is None: + raise RuntimeError("Yale API not initialized") + + access_token = await self._get_access_token() + lock = await self._resolve_lock() + await api.async_unlock(access_token, lock.device_id) + await self.refresh() + + def get_lock_info(self) -> LockInfo | None: + if self._lock is None or self._lock_detail is None: + return None + + return LockInfo( + lock_id=self._lock.device_id, + name=self._lock.device_name, + house_id=self._lock.house_id, + battery_level=self._lock_detail.battery_level, + bridge_online=self._lock_detail.bridge_is_online, + doorsense=self._lock_detail.doorsense, + ) + + def get_activities(self) -> list[ActivityView]: + return list(self._activities) + + def get_lock_status(self) -> LockStatusView: + return self._lock_status + + def get_door_status(self) -> DoorStatusView: + return self._door_status + + def get_lock_status_updated_at(self) -> datetime | None: + return self._lock_status_updated_at + + def get_door_status_updated_at(self) -> datetime | None: + return self._door_status_updated_at + + async def _poll_loop(self) -> None: + while True: + try: + await asyncio.sleep(self._settings.yale_poll_interval_seconds) + await self.refresh() + except asyncio.CancelledError: + raise + except Exception: + _LOGGER.exception("Yale poll failed") diff --git a/tests/test_yale_lock.py b/tests/test_yale_lock.py new file mode 100644 index 0000000..c21f4f4 --- /dev/null +++ b/tests/test_yale_lock.py @@ -0,0 +1,80 @@ +"""Tests for the home dashboard.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from yale_lock.config import Settings +from yale_lock.models import activity_label, serialize_activity +from yale_lock.server import create_app +from yale_lock.unifi_client import UniFiCameraClient + + +def test_activity_label_known_action() -> None: + assert activity_label("dooropen") == "Door opened" + assert activity_label("custom_event") == "Custom Event" + + +def test_serialize_activity() -> None: + activity = SimpleNamespace( + action="unlock", + activity_id="abc123", + device_id="lock-1", + device_name="Front Door", + activity_start_time=datetime(2026, 1, 1, tzinfo=UTC), + activity_type=SimpleNamespace(value="lock_operation"), + operated_by="Remote User", + calling_user={}, + ) + + view = serialize_activity(activity) + + assert view.action == "unlock" + assert view.label == "Unlocked" + assert view.operator == "Remote User" + + +def test_unifi_status_when_not_configured() -> None: + settings = Settings() + client = UniFiCameraClient(settings) + status = client.get_status() + + assert status.configured is False + assert status.connected is False + assert status.cameras == [] + + +@pytest.fixture +def test_client() -> TestClient: + settings = Settings( + YALE_USERNAME="", + YALE_PASSWORD="", + UNIFI_HOST="", + ) + app = create_app(settings) + + with patch.object(app.state.dashboard, "start", AsyncMock()), patch.object( + app.state.dashboard, "stop", AsyncMock() + ): + with TestClient(app) as client: + yield client + + +def test_status_endpoint(test_client: TestClient) -> None: + response = test_client.get("/api/status") + assert response.status_code == 200 + payload = response.json() + assert payload["auth_state"] == "not_configured" + assert payload["camera"]["configured"] is False + + +def test_index_page(test_client: TestClient) -> None: + response = test_client.get("/") + assert response.status_code == 200 + assert "Home Dashboard" in response.text + assert "UniFi Camera" in response.text From 7764e4f72e27657aedd1e416e98f8e82b1a65a05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 02:52:18 +0000 Subject: [PATCH 2/8] Modernize dashboard UI with light and dark themes Redesign the web interface with glass-style cards, improved typography, status icons, and a persistent theme switcher supporting light, dark, and system preferences. Co-authored-by: Nathan Faggian --- src/yale_lock/static/app.js | 100 ++++- src/yale_lock/static/styles.css | 594 +++++++++++++++++++++++++---- src/yale_lock/templates/index.html | 212 ++++++++-- 3 files changed, 781 insertions(+), 125 deletions(-) diff --git a/src/yale_lock/static/app.js b/src/yale_lock/static/app.js index 3850486..02b6bce 100644 --- a/src/yale_lock/static/app.js +++ b/src/yale_lock/static/app.js @@ -1,5 +1,6 @@ const config = window.DASHBOARD_CONFIG || {}; const refreshMs = Math.max(1000, (config.snapshotRefreshSeconds || 2) * 1000); +const THEME_STORAGE_KEY = "home-dashboard-theme"; const els = { connectionPill: document.getElementById("connection-pill"), @@ -18,6 +19,7 @@ const els = { lockStatusCard: document.getElementById("lock-status-card"), doorStatusCard: document.getElementById("door-status-card"), bridgeStatus: document.getElementById("bridge-status"), + bridgeDot: document.getElementById("bridge-dot"), lockUpdated: document.getElementById("lock-updated"), lockBtn: document.getElementById("lock-btn"), unlockBtn: document.getElementById("unlock-btn"), @@ -26,11 +28,22 @@ const els = { cameraMessage: document.getElementById("camera-message"), cameraStatus: document.getElementById("camera-status"), activityList: document.getElementById("activity-list"), + themeLight: document.getElementById("theme-light"), + themeSystem: document.getElementById("theme-system"), + themeDark: document.getElementById("theme-dark"), }; let selectedCameraId = null; let snapshotTimer = null; +const activityIcons = { + lock: ``, + unlock: ``, + dooropen: ``, + doorclosed: ``, + default: ``, +}; + function titleCase(value) { return String(value || "unknown").replaceAll("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()); } @@ -40,8 +53,19 @@ function formatTime(value) { return new Date(value).toLocaleString(); } +function activityIconClass(action) { + if (!action) return "default"; + if (action.includes("lock") && !action.includes("unlock")) return "lock"; + if (action.includes("unlock")) return "unlock"; + if (action.includes("dooropen") || action.includes("door_open")) return "dooropen"; + if (action.includes("doorclose") || action.includes("door_close") || action.includes("doorclosed")) { + return "doorclosed"; + } + return "default"; +} + function setPill(text, tone = "neutral") { - els.connectionPill.textContent = text; + els.connectionPill.innerHTML = `${text}`; els.connectionPill.className = `pill pill-${tone}`; } @@ -50,6 +74,38 @@ function applyStatusCard(card, value) { if (value) card.classList.add(value); } +function setCameraMessage(text) { + const messageSpan = els.cameraMessage.querySelector("span"); + if (messageSpan) { + messageSpan.textContent = text; + } else { + els.cameraMessage.textContent = text; + } + els.cameraMessage.classList.remove("hidden"); +} + +function applyTheme(mode) { + document.documentElement.dataset.theme = mode; + localStorage.setItem(THEME_STORAGE_KEY, mode); + + for (const button of [els.themeLight, els.themeSystem, els.themeDark]) { + button?.classList.remove("active"); + } + + if (mode === "light") els.themeLight?.classList.add("active"); + if (mode === "system") els.themeSystem?.classList.add("active"); + if (mode === "dark") els.themeDark?.classList.add("active"); +} + +function initTheme() { + const saved = localStorage.getItem(THEME_STORAGE_KEY) || "system"; + applyTheme(saved); + + els.themeLight?.addEventListener("click", () => applyTheme("light")); + els.themeSystem?.addEventListener("click", () => applyTheme("system")); + els.themeDark?.addEventListener("click", () => applyTheme("dark")); +} + async function api(path, options = {}) { const response = await fetch(path, { headers: { "Content-Type": "application/json", ...(options.headers || {}) }, @@ -69,19 +125,29 @@ async function api(path, options = {}) { function renderActivities(activities) { els.activityList.innerHTML = ""; if (!activities || activities.length === 0) { - els.activityList.innerHTML = '
    • No recent activity
    • '; + els.activityList.innerHTML = ` +
    • +
      +
      ${activityIcons.default}
      +
      No recent activityEvents will appear here
      +
      +
    • `; return; } for (const activity of activities) { + const iconClass = activityIconClass(activity.action); const item = document.createElement("li"); item.className = "activity-item"; item.innerHTML = ` -
      - ${activity.label} - ${activity.operator || activity.device_name || "System"} +
      +
      ${activityIcons[iconClass] || activityIcons.default}
      +
      + ${activity.label} + ${activity.operator || activity.device_name || "System"} +
      - ${formatTime(activity.timestamp)} + ${formatTime(activity.timestamp)} `; els.activityList.appendChild(item); } @@ -116,8 +182,7 @@ function updateCameraSnapshot() { }; els.cameraImage.onerror = () => { els.cameraImage.hidden = true; - els.cameraMessage.textContent = "Unable to load camera snapshot"; - els.cameraMessage.classList.remove("hidden"); + setCameraMessage("Unable to load camera snapshot"); }; els.cameraImage.src = url; } @@ -155,7 +220,14 @@ function renderStatus(status) { els.lockStatus.textContent = titleCase(status.lock_status); els.doorStatus.textContent = titleCase(status.door_status); els.batteryLevel.textContent = lock && lock.battery_level != null ? `${lock.battery_level}%` : "—"; - els.bridgeStatus.textContent = lock ? `Bridge: ${lock.bridge_online ? "Online" : "Offline"}` : "Bridge: —"; + + if (lock) { + const bridgeOnline = lock.bridge_online; + els.bridgeStatus.innerHTML = `Bridge: ${bridgeOnline ? "Online" : "Offline"}`; + } else { + els.bridgeStatus.innerHTML = `Bridge: —`; + } + els.lockUpdated.textContent = `Updated: ${formatTime(status.lock_status_updated_at || status.updated_at)}`; applyStatusCard(els.lockStatusCard, status.lock_status); @@ -169,15 +241,13 @@ function renderStatus(status) { const camera = status.camera || {}; if (!config.unifiConfigured) { - els.cameraMessage.textContent = "Set UNIFI_HOST, UNIFI_USERNAME, and UNIFI_PASSWORD in .env"; - els.cameraMessage.classList.remove("hidden"); + setCameraMessage("Set UNIFI_HOST, UNIFI_USERNAME, and UNIFI_PASSWORD in .env"); els.cameraImage.hidden = true; return; } if (!camera.connected) { - els.cameraMessage.textContent = camera.message || "UniFi Protect unavailable"; - els.cameraMessage.classList.remove("hidden"); + setCameraMessage(camera.message || "UniFi Protect unavailable"); els.cameraImage.hidden = true; } @@ -266,12 +336,12 @@ els.cameraSelect.addEventListener("change", async () => { await api(`/api/cameras/${encodeURIComponent(selectedCameraId)}/select`, { method: "POST" }); startSnapshotLoop(); } catch (error) { - els.cameraMessage.textContent = error.message; - els.cameraMessage.classList.remove("hidden"); + setCameraMessage(error.message); } }); async function init() { + initTheme(); try { const status = await api("/api/status"); renderStatus(status); diff --git a/src/yale_lock/static/styles.css b/src/yale_lock/static/styles.css index de73848..4352d23 100644 --- a/src/yale_lock/static/styles.css +++ b/src/yale_lock/static/styles.css @@ -1,34 +1,161 @@ :root { - color-scheme: dark; - --bg: #0b1020; - --panel: #121933; - --panel-border: #243056; - --text: #edf2ff; - --muted: #9aa7c7; - --primary: #4f8cff; - --primary-hover: #6ea0ff; + color-scheme: light dark; + --font: "DM Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --radius-sm: 10px; + --radius-md: 16px; + --radius-lg: 22px; + --radius-full: 999px; + --transition: 180ms ease; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-md: 0 10px 30px rgba(15, 23, 42, 0.08); + --shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.12); +} + +:root, +[data-theme="light"] { + --bg: #f4f7fb; + --bg-accent-a: rgba(79, 140, 255, 0.12); + --bg-accent-b: rgba(62, 207, 142, 0.1); + --surface: rgba(255, 255, 255, 0.82); + --surface-strong: #ffffff; + --surface-muted: rgba(255, 255, 255, 0.55); + --border: rgba(15, 23, 42, 0.08); + --border-strong: rgba(15, 23, 42, 0.12); + --text: #0f172a; + --text-secondary: #475569; + --text-muted: #64748b; + --primary: #3b6df6; + --primary-hover: #2f5fe0; + --primary-soft: rgba(59, 109, 246, 0.12); + --success: #0f9f6e; + --success-soft: rgba(15, 159, 110, 0.12); + --warning: #d97706; + --warning-soft: rgba(217, 119, 6, 0.12); + --danger: #dc2626; + --danger-soft: rgba(220, 38, 38, 0.12); + --neutral-soft: rgba(100, 116, 139, 0.12); + --camera-bg: #0f172a; + --input-bg: rgba(255, 255, 255, 0.9); + --overlay: rgba(255, 255, 255, 0.72); +} + +[data-theme="dark"] { + --bg: #0a0f1a; + --bg-accent-a: rgba(79, 140, 255, 0.18); + --bg-accent-b: rgba(62, 207, 142, 0.12); + --surface: rgba(17, 24, 39, 0.72); + --surface-strong: rgba(23, 32, 51, 0.92); + --surface-muted: rgba(255, 255, 255, 0.04); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.12); + --text: #f8fafc; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --primary: #6ea0ff; + --primary-hover: #89b2ff; + --primary-soft: rgba(110, 160, 255, 0.14); --success: #3ecf8e; + --success-soft: rgba(62, 207, 142, 0.14); --warning: #ffb020; + --warning-soft: rgba(255, 176, 32, 0.14); --danger: #ff6b6b; - --neutral: #5b6b8c; + --danger-soft: rgba(255, 107, 107, 0.14); + --neutral-soft: rgba(148, 163, 184, 0.14); + --camera-bg: #05070f; + --input-bg: rgba(255, 255, 255, 0.05); + --overlay: rgba(15, 23, 42, 0.55); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0a0f1a; + --bg-accent-a: rgba(79, 140, 255, 0.18); + --bg-accent-b: rgba(62, 207, 142, 0.12); + --surface: rgba(17, 24, 39, 0.72); + --surface-strong: rgba(23, 32, 51, 0.92); + --surface-muted: rgba(255, 255, 255, 0.04); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.12); + --text: #f8fafc; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --primary: #6ea0ff; + --primary-hover: #89b2ff; + --primary-soft: rgba(110, 160, 255, 0.14); + --success: #3ecf8e; + --success-soft: rgba(62, 207, 142, 0.14); + --warning: #ffb020; + --warning-soft: rgba(255, 176, 32, 0.14); + --danger: #ff6b6b; + --danger-soft: rgba(255, 107, 107, 0.14); + --neutral-soft: rgba(148, 163, 184, 0.14); + --camera-bg: #05070f; + --input-bg: rgba(255, 255, 255, 0.05); + --overlay: rgba(15, 23, 42, 0.55); + } } * { box-sizing: border-box; } +html { + scroll-behavior: smooth; +} + body { margin: 0; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: radial-gradient(circle at top, #172040 0%, var(--bg) 45%); + font-family: var(--font); + background: var(--bg); color: var(--text); min-height: 100vh; + transition: background-color var(--transition), color var(--transition); +} + +.ambient { + position: fixed; + border-radius: 50%; + filter: blur(80px); + pointer-events: none; + z-index: 0; +} + +.ambient-a { + width: 420px; + height: 420px; + top: -120px; + right: -80px; + background: var(--bg-accent-a); +} + +.ambient-b { + width: 360px; + height: 360px; + bottom: -100px; + left: -60px; + background: var(--bg-accent-b); } .page { - max-width: 1200px; + position: relative; + z-index: 1; + max-width: 1240px; margin: 0 auto; - padding: 24px; + padding: 28px 20px 40px; +} + +.glass { + background: var(--surface); + border: 1px solid var(--border); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + box-shadow: var(--shadow-md); +} + +.panel { + border-radius: var(--radius-lg); + padding: 22px; + transition: background-color var(--transition), border-color var(--transition), box-shadow var(--transition); } .header, @@ -36,7 +163,9 @@ body { .status-row, .actions, .meta, -.header-actions { +.header-actions, +.brand, +.panel-heading { display: flex; gap: 12px; } @@ -44,33 +173,66 @@ body { .header { align-items: center; justify-content: space-between; - margin-bottom: 24px; + margin-bottom: 22px; + padding: 18px 22px; +} + +.brand { + align-items: center; +} + +.brand-mark, +.panel-icon, +.status-icon { + display: grid; + place-items: center; + border-radius: var(--radius-md); +} + +.brand-mark { + width: 48px; + height: 48px; + background: var(--primary-soft); + color: var(--primary); +} + +.brand-mark svg, +.panel-icon svg, +.status-icon svg, +.btn svg, +.icon-btn svg, +.callout svg { + width: 20px; + height: 20px; } .eyebrow { margin: 0 0 4px; - color: var(--muted); - font-size: 0.85rem; + color: var(--text-muted); + font-size: 0.78rem; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.1em; +} + +h1 { + margin: 0; + font-size: clamp(1.5rem, 2vw, 1.9rem); + font-weight: 700; + letter-spacing: -0.03em; } -h1, h2 { +h2 { margin: 0; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: -0.02em; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 20px; -} - -.panel { - background: rgba(18, 25, 51, 0.92); - border: 1px solid var(--panel-border); - border-radius: 18px; - padding: 20px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + gap: 18px; } .activity-panel { @@ -80,48 +242,102 @@ h1, h2 { .panel-header { align-items: center; justify-content: space-between; - margin-bottom: 16px; + margin-bottom: 18px; +} + +.panel-heading { + align-items: center; +} + +.panel-heading.compact h2 + .muted, +.panel-heading.compact p.muted { + margin: 2px 0 0; + font-size: 0.88rem; +} + +.panel-icon { + width: 42px; + height: 42px; + flex-shrink: 0; + background: var(--surface-muted); + color: var(--primary); + border: 1px solid var(--border); } .status-row { - margin-bottom: 16px; + margin-bottom: 18px; } .status-card { + display: flex; + align-items: center; + gap: 12px; flex: 1; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 14px; + min-width: 0; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 14px; + transition: border-color var(--transition), transform var(--transition); +} + +.status-card:hover { + transform: translateY(-1px); +} + +.status-icon { + width: 40px; + height: 40px; + flex-shrink: 0; + background: var(--neutral-soft); + color: var(--text-secondary); } .label { - margin: 0 0 6px; - color: var(--muted); - font-size: 0.85rem; + margin: 0 0 4px; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 600; } .value { margin: 0; - font-size: 1.4rem; + font-size: 1.15rem; font-weight: 700; + letter-spacing: -0.02em; } .muted { - color: var(--muted); + color: var(--text-muted); } .btn, .select, -input { +input, +.icon-btn { font: inherit; } -.btn { +.btn, +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; border: 0; - border-radius: 12px; - padding: 10px 16px; + border-radius: var(--radius-sm); cursor: pointer; + transition: background-color var(--transition), color var(--transition), transform var(--transition), opacity var(--transition); +} + +.btn { + padding: 11px 16px; + font-weight: 600; +} + +.btn:hover:not(:disabled), +.icon-btn:hover { + transform: translateY(-1px); } .btn-primary { @@ -129,28 +345,60 @@ input { color: white; } -.btn-primary:hover { +.btn-primary:hover:not(:disabled) { background: var(--primary-hover); } .btn-secondary { - background: rgba(255, 255, 255, 0.08); + background: var(--surface-muted); color: var(--text); + border: 1px solid var(--border); } .btn:disabled { - opacity: 0.5; + opacity: 0.45; cursor: not-allowed; } +.theme-switch { + display: inline-flex; + padding: 4px; + border-radius: var(--radius-full); + background: var(--surface-muted); + border: 1px solid var(--border); +} + +.icon-btn { + width: 36px; + height: 36px; + padding: 0; + border-radius: var(--radius-full); + background: transparent; + color: var(--text-muted); +} + +.icon-btn.active { + background: var(--surface-strong); + color: var(--text); + box-shadow: var(--shadow-sm); +} + .select, input { width: 100%; - border: 1px solid var(--panel-border); - border-radius: 12px; - background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--input-bg); color: var(--text); - padding: 10px 12px; + padding: 11px 12px; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.select:focus, +input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-soft); } .select { @@ -160,39 +408,142 @@ input { .stack { display: grid; - gap: 12px; + gap: 14px; } label { display: grid; - gap: 6px; + gap: 8px; +} + +label span { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-secondary); } .pill { display: inline-flex; align-items: center; - border-radius: 999px; - padding: 6px 12px; - font-size: 0.85rem; + gap: 8px; + border-radius: var(--radius-full); + padding: 7px 12px; + font-size: 0.84rem; font-weight: 600; + border: 1px solid transparent; +} + +.pill-dot, +.meta-dot, +.live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.pill-success { + background: var(--success-soft); + color: var(--success); + border-color: rgba(15, 159, 110, 0.12); +} + +.pill-warning { + background: var(--warning-soft); + color: var(--warning); } -.pill-success { background: rgba(62, 207, 142, 0.15); color: var(--success); } -.pill-warning { background: rgba(255, 176, 32, 0.15); color: var(--warning); } -.pill-danger { background: rgba(255, 107, 107, 0.15); color: var(--danger); } -.pill-neutral { background: rgba(91, 107, 140, 0.2); color: var(--muted); } +.pill-danger { + background: var(--danger-soft); + color: var(--danger); +} + +.pill-neutral { + background: var(--neutral-soft); + color: var(--text-muted); +} -.status-card.locked { border-color: rgba(62, 207, 142, 0.35); } -.status-card.unlocked { border-color: rgba(255, 176, 32, 0.35); } -.status-card.open { border-color: rgba(255, 107, 107, 0.35); } -.status-card.closed { border-color: rgba(62, 207, 142, 0.35); } +.status-card.locked { + border-color: rgba(15, 159, 110, 0.28); +} + +.status-card.locked .status-icon { + background: var(--success-soft); + color: var(--success); +} + +.status-card.unlocked { + border-color: rgba(217, 119, 6, 0.28); +} + +.status-card.unlocked .status-icon { + background: var(--warning-soft); + color: var(--warning); +} + +.status-card.open { + border-color: rgba(220, 38, 38, 0.28); +} + +.status-card.open .status-icon { + background: var(--danger-soft); + color: var(--danger); +} + +.status-card.closed { + border-color: rgba(15, 159, 110, 0.28); +} + +.status-card.closed .status-icon { + background: var(--success-soft); + color: var(--success); +} + +.actions { + margin-bottom: 14px; +} + +.meta { + flex-wrap: wrap; + padding-top: 4px; +} + +.meta-item { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 0.88rem; +} + +.meta-dot.online { + background: var(--success); + box-shadow: 0 0 0 4px var(--success-soft); +} + +.meta-dot.offline { + background: var(--danger); +} + +.callout { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + padding: 12px 14px; + border-radius: var(--radius-sm); + background: var(--neutral-soft); + color: var(--text-secondary); + font-size: 0.92rem; +} .camera-frame { + position: relative; aspect-ratio: 16 / 9; - background: #05070f; - border-radius: 14px; + background: var(--camera-bg); + border-radius: var(--radius-md); overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.06); + border: 1px solid var(--border); } .camera-frame img { @@ -202,8 +553,38 @@ label { display: block; } -.camera-message { - margin-bottom: 12px; +.camera-overlay { + position: absolute; + top: 12px; + left: 12px; + z-index: 2; + pointer-events: none; +} + +.live-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--radius-full); + background: var(--overlay); + backdrop-filter: blur(8px); + color: var(--text); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.live-dot { + background: var(--danger); + box-shadow: 0 0 0 4px var(--danger-soft); + animation: pulse 1.6s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } } .activity-list { @@ -216,17 +597,67 @@ label { .activity-item { display: flex; + align-items: center; justify-content: space-between; gap: 16px; - padding: 12px 14px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.05); + padding: 14px 16px; + border-radius: var(--radius-md); + background: var(--surface-strong); + border: 1px solid var(--border); + transition: transform var(--transition), border-color var(--transition); +} + +.activity-item:hover { + transform: translateY(-1px); +} + +.activity-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.activity-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: var(--radius-sm); + flex-shrink: 0; + background: var(--neutral-soft); + color: var(--text-secondary); +} + +.activity-icon svg { + width: 18px; + height: 18px; } -.activity-item strong { +.activity-icon.lock { background: var(--success-soft); color: var(--success); } +.activity-icon.unlock { background: var(--warning-soft); color: var(--warning); } +.activity-icon.dooropen { background: var(--danger-soft); color: var(--danger); } +.activity-icon.doorclosed { background: var(--primary-soft); color: var(--primary); } + +.activity-copy strong { display: block; - margin-bottom: 4px; + margin-bottom: 2px; + font-size: 0.96rem; +} + +.activity-copy span { + font-size: 0.86rem; +} + +.activity-time { + flex-shrink: 0; + font-size: 0.82rem; + color: var(--text-muted); + white-space: nowrap; +} + +.auth-panel { + margin-bottom: 18px; } .hidden { @@ -239,7 +670,24 @@ label { } .header { + flex-direction: column; + align-items: stretch; + } + + .header-actions { + flex-wrap: wrap; + } + + .status-row { + flex-direction: column; + } + + .activity-item { flex-direction: column; align-items: flex-start; } + + .activity-time { + white-space: normal; + } } diff --git a/src/yale_lock/templates/index.html b/src/yale_lock/templates/index.html index f02e92a..02746b5 100644 --- a/src/yale_lock/templates/index.html +++ b/src/yale_lock/templates/index.html @@ -1,98 +1,236 @@ - + Home Dashboard + + + +
      +
      +
      -
      -
      -

      Home Dashboard

      -

      Yale Lock & UniFi Camera

      +
      +
      + +
      +

      Home Dashboard

      +

      Yale Lock & UniFi Camera

      +
      - Connecting… - + + + Connecting… + +
      + + + +
      +
      -
      + + + Loading weather… + Connecting… @@ -240,8 +255,10 @@

      Recent Activity

      diff --git a/src/yale_lock/weather.py b/src/yale_lock/weather.py new file mode 100644 index 0000000..d663a21 --- /dev/null +++ b/src/yale_lock/weather.py @@ -0,0 +1,185 @@ +"""Weather and time-of-day ambience for the dashboard background.""" + +from __future__ import annotations + +import logging +from datetime import datetime +from enum import StrEnum +from typing import Any +from zoneinfo import ZoneInfo + +import httpx + +from yale_lock.config import Settings +from yale_lock.models import AmbienceSnapshot + +_LOGGER = logging.getLogger(__name__) +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" + + +class TimeOfDay(StrEnum): + NIGHT = "night" + DAWN = "dawn" + MORNING = "morning" + AFTERNOON = "afternoon" + EVENING = "evening" + + +class WeatherCondition(StrEnum): + CLEAR = "clear" + PARTLY_CLOUDY = "partly-cloudy" + CLOUDY = "cloudy" + FOG = "fog" + RAIN = "rain" + SNOW = "snow" + STORM = "storm" + + +WEATHER_LABELS = { + WeatherCondition.CLEAR: "Clear", + WeatherCondition.PARTLY_CLOUDY: "Partly cloudy", + WeatherCondition.CLOUDY: "Cloudy", + WeatherCondition.FOG: "Foggy", + WeatherCondition.RAIN: "Rainy", + WeatherCondition.SNOW: "Snowy", + WeatherCondition.STORM: "Stormy", +} + + +def time_of_day_from_hour(hour: int, *, is_day: bool | None = None) -> TimeOfDay: + if is_day is False or hour >= 21 or hour < 5: + return TimeOfDay.NIGHT + if hour < 7: + return TimeOfDay.DAWN + if hour < 12: + return TimeOfDay.MORNING + if hour < 17: + return TimeOfDay.AFTERNOON + if hour < 21: + return TimeOfDay.EVENING + return TimeOfDay.NIGHT + + +def weather_condition_from_code(code: int) -> WeatherCondition: + if code == 0: + return WeatherCondition.CLEAR + if code in {1, 2}: + return WeatherCondition.PARTLY_CLOUDY + if code == 3: + return WeatherCondition.CLOUDY + if code in {45, 48}: + return WeatherCondition.FOG + if code in {51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82}: + return WeatherCondition.RAIN + if code in {71, 73, 75, 77, 85, 86}: + return WeatherCondition.SNOW + if code in {95, 96, 99}: + return WeatherCondition.STORM + return WeatherCondition.CLOUDY + + +def build_ambience_snapshot( + *, + configured: bool, + time_of_day: TimeOfDay, + weather: WeatherCondition, + temperature_c: float | None = None, + cloud_cover: int | None = None, + is_day: bool = True, + timezone: str | None = None, + location_name: str | None = None, + local_time: datetime | None = None, + message: str | None = None, +) -> AmbienceSnapshot: + return AmbienceSnapshot( + configured=configured, + time_of_day=time_of_day.value, + weather=weather.value, + weather_label=WEATHER_LABELS[weather], + temperature_c=temperature_c, + cloud_cover=cloud_cover, + is_day=is_day, + timezone=timezone, + location_name=location_name, + local_time=local_time, + message=message, + ) + + +class WeatherService: + """Fetches local weather from Open-Meteo (no API key required).""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self._snapshot = build_ambience_snapshot( + configured=settings.weather_configured, + time_of_day=TimeOfDay.MORNING, + weather=WeatherCondition.CLEAR, + message="Weather location not configured", + ) + + @property + def snapshot(self) -> AmbienceSnapshot: + return self._snapshot + + async def refresh(self) -> AmbienceSnapshot: + if not self._settings.weather_configured: + now = datetime.now().astimezone() + time_of_day = time_of_day_from_hour(now.hour) + self._snapshot = build_ambience_snapshot( + configured=False, + time_of_day=time_of_day, + weather=WeatherCondition.CLEAR, + local_time=now, + message="Set WEATHER_LATITUDE and WEATHER_LONGITUDE in .env", + ) + return self._snapshot + + try: + payload = await self._fetch_open_meteo() + current = payload["current"] + timezone = payload.get("timezone", "UTC") + local_time = datetime.fromisoformat(current["time"]).replace( + tzinfo=ZoneInfo(timezone) + ) + weather_code = int(current["weather_code"]) + is_day = bool(current["is_day"]) + condition = weather_condition_from_code(weather_code) + time_of_day = time_of_day_from_hour(local_time.hour, is_day=is_day) + + self._snapshot = build_ambience_snapshot( + configured=True, + time_of_day=time_of_day, + weather=condition, + temperature_c=float(current["temperature_2m"]), + cloud_cover=int(current.get("cloud_cover", 0)), + is_day=is_day, + timezone=timezone, + location_name=self._settings.weather_location_name or None, + local_time=local_time, + ) + except Exception as exc: + _LOGGER.exception("Weather refresh failed") + now = datetime.now().astimezone() + self._snapshot = build_ambience_snapshot( + configured=True, + time_of_day=time_of_day_from_hour(now.hour), + weather=WeatherCondition.CLOUDY, + local_time=now, + message=str(exc), + ) + + return self._snapshot + + async def _fetch_open_meteo(self) -> dict[str, Any]: + params = { + "latitude": self._settings.weather_latitude, + "longitude": self._settings.weather_longitude, + "current": "temperature_2m,is_day,weather_code,cloud_cover", + "timezone": self._settings.weather_timezone or "auto", + } + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(OPEN_METEO_URL, params=params) + response.raise_for_status() + payload: dict[str, Any] = response.json() + return payload diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b0dcf4c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +"""Shared pytest fixtures.""" + +from __future__ import annotations + +from collections.abc import Iterator +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from yale_lock.config import Settings +from yale_lock.server import create_app + + +@pytest.fixture +def test_client() -> Iterator[TestClient]: + settings = Settings( + YALE_USERNAME="", + YALE_PASSWORD="", + UNIFI_HOST="", + ) + app = create_app(settings) + + with patch.object(app.state.dashboard, "start", AsyncMock()), patch.object( + app.state.dashboard, "stop", AsyncMock() + ): + with TestClient(app) as client: + yield client diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..970ff68 --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,55 @@ +"""Tests for weather ambience.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from yale_lock.weather import ( + TimeOfDay, + WeatherCondition, + time_of_day_from_hour, + weather_condition_from_code, +) + + +@pytest.mark.parametrize( + ("hour", "is_day", "expected"), + [ + (3, None, TimeOfDay.NIGHT), + (6, None, TimeOfDay.DAWN), + (9, None, TimeOfDay.MORNING), + (14, None, TimeOfDay.AFTERNOON), + (19, None, TimeOfDay.EVENING), + (22, None, TimeOfDay.NIGHT), + (12, False, TimeOfDay.NIGHT), + ], +) +def test_time_of_day_from_hour(hour: int, is_day: bool | None, expected: TimeOfDay) -> None: + assert time_of_day_from_hour(hour, is_day=is_day) == expected + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + (0, WeatherCondition.CLEAR), + (2, WeatherCondition.PARTLY_CLOUDY), + (3, WeatherCondition.CLOUDY), + (45, WeatherCondition.FOG), + (61, WeatherCondition.RAIN), + (71, WeatherCondition.SNOW), + (95, WeatherCondition.STORM), + ], +) +def test_weather_condition_from_code(code: int, expected: WeatherCondition) -> None: + assert weather_condition_from_code(code) == expected + + +def test_ambience_endpoint(test_client: TestClient) -> None: + response = test_client.get("/api/ambience") + assert response.status_code == 200 + payload = response.json() + assert "time_of_day" in payload + assert "weather" in payload + assert payload["time_of_day"] in {item.value for item in TimeOfDay} diff --git a/tests/test_yale_lock.py b/tests/test_yale_lock.py index c21f4f4..1de578d 100644 --- a/tests/test_yale_lock.py +++ b/tests/test_yale_lock.py @@ -4,14 +4,11 @@ from datetime import UTC, datetime from types import SimpleNamespace -from unittest.mock import AsyncMock, patch -import pytest from fastapi.testclient import TestClient from yale_lock.config import Settings from yale_lock.models import activity_label, serialize_activity -from yale_lock.server import create_app from yale_lock.unifi_client import UniFiCameraClient @@ -49,22 +46,6 @@ def test_unifi_status_when_not_configured() -> None: assert status.cameras == [] -@pytest.fixture -def test_client() -> TestClient: - settings = Settings( - YALE_USERNAME="", - YALE_PASSWORD="", - UNIFI_HOST="", - ) - app = create_app(settings) - - with patch.object(app.state.dashboard, "start", AsyncMock()), patch.object( - app.state.dashboard, "stop", AsyncMock() - ): - with TestClient(app) as client: - yield client - - def test_status_endpoint(test_client: TestClient) -> None: response = test_client.get("/api/status") assert response.status_code == 200 From e5bb116a83bd3c5d9c78f70f5493a08a4f163708 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 03:35:31 +0000 Subject: [PATCH 4/8] Restyle dashboard with Fuselab Creative design language Adopt Liter typography, ultramarine/blue palette, KPI accent cards, gradient actions, mesh grid background, and enterprise dashboard hierarchy inspired by Fuselab Creative case studies. Co-authored-by: Nathan Faggian --- src/yale_lock/static/styles.css | 810 +++++++++++++---------------- src/yale_lock/templates/index.html | 24 +- tests/test_yale_lock.py | 2 +- 3 files changed, 377 insertions(+), 459 deletions(-) diff --git a/src/yale_lock/static/styles.css b/src/yale_lock/static/styles.css index 8714c53..5c9e491 100644 --- a/src/yale_lock/static/styles.css +++ b/src/yale_lock/static/styles.css @@ -1,124 +1,177 @@ +/* Fuselab-inspired design tokens — https://fuselabcreative.com */ +@font-face { + font-family: "Liter"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("https://fuselabcreative.com/wp-content/themes/fuselab/assets/fonts/liter/files/liter-regular.woff2") format("woff2"); +} + +@font-face { + font-family: "Liter"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("https://fuselabcreative.com/wp-content/themes/fuselab/assets/fonts/liter/files/liter-semibold.woff2") format("woff2"); +} + +@font-face { + font-family: "Liter"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("https://fuselabcreative.com/wp-content/themes/fuselab/assets/fonts/liter/files/liter-bold.woff2") format("woff2"); +} + :root { color-scheme: light dark; - --font: "DM Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --radius-sm: 10px; - --radius-md: 16px; - --radius-lg: 22px; + + /* Fuselab primitives */ + --fl-blue: #1c63ec; + --fl-blue-xr: #2f80ed; + --fl-ultramarine: #7000ff; + --fl-green: #1dec86; + --fl-gray-5: #f3f4f5; + --fl-gray-10: #e8e9ec; + --fl-gray-20: #ced0d6; + --fl-gray-40: #9fa3ab; + --fl-gray-60: #70747e; + --fl-gray-80: #424750; + --fl-gray-90: #2b3037; + --fl-gray-100: #091528; + --fl-gradient: radial-gradient(50% 50% at 50% 50%, #4769ff 37.64%, #7000ff 100%); + --fl-gradient-linear: linear-gradient(135deg, #1c63ec 0%, #7000ff 100%); + + --font: "Liter", ui-sans-serif, system-ui, sans-serif; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; --radius-full: 999px; - --transition: 180ms ease; - --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); - --shadow-md: 0 10px 30px rgba(15, 23, 42, 0.08); - --shadow-lg: 0 24px 60px rgba(15, 23, 42, 0.12); + --transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --shadow-sm: 0 1px 2px rgba(9, 21, 40, 0.06); + --shadow-md: 0 8px 24px rgba(9, 21, 40, 0.08); + --shadow-lg: 0 20px 48px rgba(9, 21, 40, 0.14); } :root, [data-theme="light"] { - --bg: #f4f7fb; - --bg-accent-a: rgba(79, 140, 255, 0.12); - --bg-accent-b: rgba(62, 207, 142, 0.1); - --surface: rgba(255, 255, 255, 0.82); + --bg: var(--fl-gray-5); + --text: var(--fl-gray-100); + --text-secondary: var(--fl-gray-80); + --text-muted: var(--fl-gray-60); + --surface: rgba(255, 255, 255, 0.88); --surface-strong: #ffffff; - --surface-muted: rgba(255, 255, 255, 0.55); - --border: rgba(15, 23, 42, 0.08); - --border-strong: rgba(15, 23, 42, 0.12); - --text: #0f172a; - --text-secondary: #475569; - --text-muted: #64748b; - --primary: #3b6df6; - --primary-hover: #2f5fe0; - --primary-soft: rgba(59, 109, 246, 0.12); - --success: #0f9f6e; - --success-soft: rgba(15, 159, 110, 0.12); - --warning: #d97706; - --warning-soft: rgba(217, 119, 6, 0.12); - --danger: #dc2626; - --danger-soft: rgba(220, 38, 38, 0.12); - --neutral-soft: rgba(100, 116, 139, 0.12); - --camera-bg: #0f172a; - --input-bg: rgba(255, 255, 255, 0.9); - --overlay: rgba(255, 255, 255, 0.72); + --surface-muted: rgba(255, 255, 255, 0.6); + --surface-elevated: #ffffff; + --border: var(--fl-gray-10); + --border-strong: var(--fl-gray-20); + --primary: var(--fl-blue); + --primary-hover: #1554c7; + --primary-soft: rgba(28, 99, 236, 0.1); + --accent: var(--fl-ultramarine); + --accent-soft: rgba(112, 0, 255, 0.1); + --success: #0a9f62; + --success-soft: rgba(29, 236, 134, 0.14); + --warning: #c77700; + --warning-soft: rgba(199, 119, 0, 0.12); + --danger: #d92d20; + --danger-soft: rgba(217, 45, 32, 0.1); + --neutral-soft: rgba(112, 116, 126, 0.1); + --camera-bg: var(--fl-gray-100); + --input-bg: #ffffff; + --overlay: rgba(255, 255, 255, 0.82); + --mesh-a: rgba(112, 0, 255, 0.08); + --mesh-b: rgba(28, 99, 236, 0.1); + --grid-line: rgba(9, 21, 40, 0.04); } [data-theme="dark"] { - --bg: #0a0f1a; - --bg-accent-a: rgba(79, 140, 255, 0.18); - --bg-accent-b: rgba(62, 207, 142, 0.12); - --surface: rgba(17, 24, 39, 0.72); - --surface-strong: rgba(23, 32, 51, 0.92); + --bg: var(--fl-gray-100); + --text: #f3f4f5; + --text-secondary: var(--fl-gray-20); + --text-muted: var(--fl-gray-40); + --surface: rgba(33, 37, 41, 0.72); + --surface-strong: rgba(43, 48, 55, 0.95); --surface-muted: rgba(255, 255, 255, 0.04); + --surface-elevated: #2b3037; --border: rgba(255, 255, 255, 0.08); --border-strong: rgba(255, 255, 255, 0.12); - --text: #f8fafc; - --text-secondary: #cbd5e1; - --text-muted: #94a3b8; - --primary: #6ea0ff; - --primary-hover: #89b2ff; - --primary-soft: rgba(110, 160, 255, 0.14); - --success: #3ecf8e; - --success-soft: rgba(62, 207, 142, 0.14); + --primary: var(--fl-blue-xr); + --primary-hover: #4d95f0; + --primary-soft: rgba(47, 128, 237, 0.16); + --accent: #8f3dff; + --accent-soft: rgba(112, 0, 255, 0.18); + --success: var(--fl-green); + --success-soft: rgba(29, 236, 134, 0.14); --warning: #ffb020; --warning-soft: rgba(255, 176, 32, 0.14); --danger: #ff6b6b; --danger-soft: rgba(255, 107, 107, 0.14); - --neutral-soft: rgba(148, 163, 184, 0.14); - --camera-bg: #05070f; + --neutral-soft: rgba(159, 163, 171, 0.12); + --camera-bg: #050a14; --input-bg: rgba(255, 255, 255, 0.05); - --overlay: rgba(15, 23, 42, 0.55); + --overlay: rgba(9, 21, 40, 0.72); + --mesh-a: rgba(112, 0, 255, 0.22); + --mesh-b: rgba(28, 99, 236, 0.18); + --grid-line: rgba(255, 255, 255, 0.04); } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { - --bg: #0a0f1a; - --bg-accent-a: rgba(79, 140, 255, 0.18); - --bg-accent-b: rgba(62, 207, 142, 0.12); - --surface: rgba(17, 24, 39, 0.72); - --surface-strong: rgba(23, 32, 51, 0.92); + --bg: var(--fl-gray-100); + --text: #f3f4f5; + --text-secondary: var(--fl-gray-20); + --text-muted: var(--fl-gray-40); + --surface: rgba(33, 37, 41, 0.72); + --surface-strong: rgba(43, 48, 55, 0.95); --surface-muted: rgba(255, 255, 255, 0.04); + --surface-elevated: #2b3037; --border: rgba(255, 255, 255, 0.08); --border-strong: rgba(255, 255, 255, 0.12); - --text: #f8fafc; - --text-secondary: #cbd5e1; - --text-muted: #94a3b8; - --primary: #6ea0ff; - --primary-hover: #89b2ff; - --primary-soft: rgba(110, 160, 255, 0.14); - --success: #3ecf8e; - --success-soft: rgba(62, 207, 142, 0.14); + --primary: var(--fl-blue-xr); + --primary-hover: #4d95f0; + --primary-soft: rgba(47, 128, 237, 0.16); + --accent: #8f3dff; + --accent-soft: rgba(112, 0, 255, 0.18); + --success: var(--fl-green); + --success-soft: rgba(29, 236, 134, 0.14); --warning: #ffb020; --warning-soft: rgba(255, 176, 32, 0.14); --danger: #ff6b6b; --danger-soft: rgba(255, 107, 107, 0.14); - --neutral-soft: rgba(148, 163, 184, 0.14); - --camera-bg: #05070f; + --neutral-soft: rgba(159, 163, 171, 0.12); + --camera-bg: #050a14; --input-bg: rgba(255, 255, 255, 0.05); - --overlay: rgba(15, 23, 42, 0.55); + --overlay: rgba(9, 21, 40, 0.72); + --mesh-a: rgba(112, 0, 255, 0.22); + --mesh-b: rgba(28, 99, 236, 0.18); + --grid-line: rgba(255, 255, 255, 0.04); } } -* { - box-sizing: border-box; -} +* { box-sizing: border-box; } -html { - scroll-behavior: smooth; -} +html { scroll-behavior: smooth; } body { margin: 0; font-family: var(--font); + font-size: 16px; + line-height: 1.5; background: transparent; color: var(--text); min-height: 100vh; - transition: color var(--transition); + -webkit-font-smoothing: antialiased; } +/* Dynamic sky + Fuselab mesh */ .sky-background { --sky-top: #8ec5ff; --sky-mid: #c8e4ff; --sky-bottom: #eef6ff; --orb-color: #ffd76a; - --orb-glow: rgba(255, 215, 106, 0.45); - --cloud-opacity: 0.18; + --orb-glow: rgba(255, 215, 106, 0.35); + --cloud-opacity: 0.14; --fog-opacity: 0; --precip-opacity: 0; --star-opacity: 0; @@ -127,16 +180,16 @@ body { z-index: 0; overflow: hidden; pointer-events: none; - transition: --sky-top 1.2s ease, --sky-mid 1.2s ease, --sky-bottom 1.2s ease; } -.sky-layer, .sky-gradient, .sky-stars, .sky-orb, .sky-clouds, .sky-fog, -.sky-precip { +.sky-precip, +.fuselab-mesh, +.fuselab-grid { position: absolute; inset: 0; } @@ -144,69 +197,78 @@ body { .sky-gradient { background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-mid) 42%, var(--sky-bottom) 100%); transition: background 1.4s ease; + opacity: 0.55; +} + +[data-theme="dark"] .sky-gradient, +:root:not([data-theme="light"]) .sky-gradient { + opacity: 0.35; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .sky-gradient { opacity: 0.35; } +} + +.fuselab-mesh { + background: + radial-gradient(ellipse 70% 55% at 15% 0%, var(--mesh-a), transparent 60%), + radial-gradient(ellipse 55% 45% at 85% 10%, var(--mesh-b), transparent 55%), + radial-gradient(ellipse 40% 35% at 50% 100%, var(--mesh-a), transparent 50%); +} + +.fuselab-grid { + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: linear-gradient(180deg, black, transparent 85%); } .sky-stars { opacity: var(--star-opacity); background-image: radial-gradient(1px 1px at 20px 30px, rgba(255, 255, 255, 0.9), transparent), - radial-gradient(1px 1px at 80px 120px, rgba(255, 255, 255, 0.7), transparent), - radial-gradient(1.5px 1.5px at 160px 80px, rgba(255, 255, 255, 0.8), transparent), - radial-gradient(1px 1px at 240px 160px, rgba(255, 255, 255, 0.6), transparent), - radial-gradient(1px 1px at 320px 60px, rgba(255, 255, 255, 0.75), transparent), - radial-gradient(1px 1px at 420px 140px, rgba(255, 255, 255, 0.65), transparent); - background-size: 460px 200px; + radial-gradient(1px 1px at 120px 80px, rgba(255, 255, 255, 0.7), transparent), + radial-gradient(1.5px 1.5px at 240px 140px, rgba(255, 255, 255, 0.8), transparent); + background-size: 320px 180px; animation: twinkle 8s ease-in-out infinite alternate; } .sky-orb { - width: 120px; - height: 120px; - left: 72%; - top: 12%; + width: 100px; + height: 100px; + left: 78%; + top: 10%; border-radius: 50%; background: radial-gradient(circle at 35% 35%, #fff8dc 0%, var(--orb-color) 55%, transparent 100%); - box-shadow: 0 0 80px 20px var(--orb-glow); - opacity: 0.95; - transition: background 1.2s ease, box-shadow 1.2s ease, top 1.2s ease, left 1.2s ease; + box-shadow: 0 0 60px 16px var(--orb-glow); animation: orb-float 12s ease-in-out infinite; } -.sky-clouds { - opacity: var(--cloud-opacity); - transition: opacity 1s ease; -} +.sky-clouds { opacity: var(--cloud-opacity); transition: opacity 1s ease; } .cloud { position: absolute; display: block; border-radius: 50%; - background: rgba(255, 255, 255, 0.82); + background: rgba(255, 255, 255, 0.75); filter: blur(2px); - box-shadow: - 24px 8px 0 -4px rgba(255, 255, 255, 0.75), - 48px 0 0 -8px rgba(255, 255, 255, 0.65); + box-shadow: 24px 8px 0 -4px rgba(255, 255, 255, 0.65); } -.cloud-1 { width: 90px; height: 42px; top: 16%; left: 8%; animation: cloud-drift 48s linear infinite; } -.cloud-2 { width: 120px; height: 52px; top: 24%; left: 42%; animation: cloud-drift 62s linear infinite reverse; } -.cloud-3 { width: 76px; height: 36px; top: 10%; left: 68%; animation: cloud-drift 54s linear infinite; } -.cloud-4 { width: 104px; height: 46px; top: 30%; left: 82%; animation: cloud-drift 70s linear infinite reverse; } +.cloud-1 { width: 90px; height: 42px; top: 14%; left: 8%; animation: cloud-drift 48s linear infinite; } +.cloud-2 { width: 120px; height: 52px; top: 22%; left: 42%; animation: cloud-drift 62s linear infinite reverse; } +.cloud-3 { width: 76px; height: 36px; top: 8%; left: 68%; animation: cloud-drift 54s linear infinite; } +.cloud-4 { width: 104px; height: 46px; top: 28%; left: 82%; animation: cloud-drift 70s linear infinite reverse; } .sky-fog { opacity: var(--fog-opacity); - background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.55) 55%, rgba(255, 255, 255, 0.72) 100%); - transition: opacity 1s ease; + background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.5) 55%, rgba(255, 255, 255, 0.65) 100%); } -.sky-precip { - opacity: var(--precip-opacity); - background-image: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.08) 100%); - overflow: hidden; -} +.sky-precip { opacity: var(--precip-opacity); overflow: hidden; } -.sky-precip::before, -.sky-precip::after { +.sky-precip::before { content: ""; position: absolute; inset: -100% 0 0; @@ -215,14 +277,12 @@ body { .sky-background[data-weather="rain"] .sky-precip::before, .sky-background[data-weather="storm"] .sky-precip::before { - background-image: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.55) 100%); + background-image: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.5) 100%); background-size: 2px 18px; animation: rain-fall 0.7s linear infinite; } -.sky-background[data-weather="storm"] .sky-precip::before { - animation-duration: 0.45s; -} +.sky-background[data-weather="storm"] .sky-precip::before { animation-duration: 0.45s; } .sky-background[data-weather="snow"] .sky-precip::before { background-image: radial-gradient(circle, rgba(255, 255, 255, 0.95) 1px, transparent 1px); @@ -231,136 +291,59 @@ body { } .sky-background[data-time="night"] { - --sky-top: #0a1028; - --sky-mid: #121a38; - --sky-bottom: #1a2140; - --orb-color: #e8edf8; - --orb-glow: rgba(232, 237, 248, 0.22); - --star-opacity: 0.85; + --sky-top: #0a1028; --sky-mid: #121a38; --sky-bottom: #1a2140; + --orb-color: #e8edf8; --orb-glow: rgba(232, 237, 248, 0.18); --star-opacity: 0.85; } - .sky-background[data-time="dawn"] { - --sky-top: #5b4b8a; - --sky-mid: #f39a7b; - --sky-bottom: #ffd9a8; - --orb-color: #ffb36b; - --orb-glow: rgba(255, 179, 107, 0.4); + --sky-top: #5b4b8a; --sky-mid: #f39a7b; --sky-bottom: #ffd9a8; + --orb-color: #ffb36b; --orb-glow: rgba(255, 179, 107, 0.35); } - .sky-background[data-time="morning"] { - --sky-top: #5eb2ff; - --sky-mid: #9ed0ff; - --sky-bottom: #e8f4ff; - --orb-color: #ffe27a; - --orb-glow: rgba(255, 226, 122, 0.42); + --sky-top: #5eb2ff; --sky-mid: #9ed0ff; --sky-bottom: #e8f4ff; } - .sky-background[data-time="afternoon"] { - --sky-top: #4aa8ff; - --sky-mid: #7ec3ff; - --sky-bottom: #d8ecff; - --orb-color: #ffd34d; - --orb-glow: rgba(255, 211, 77, 0.45); + --sky-top: #4aa8ff; --sky-mid: #7ec3ff; --sky-bottom: #d8ecff; } - .sky-background[data-time="evening"] { - --sky-top: #3d2d68; - --sky-mid: #d86b4d; - --sky-bottom: #ffb27a; - --orb-color: #ff9b4a; - --orb-glow: rgba(255, 155, 74, 0.38); -} - -.sky-background[data-time="night"] .sky-orb { - left: 18%; - top: 10%; - width: 72px; - height: 72px; - box-shadow: 0 0 40px 10px var(--orb-glow); -} - -.sky-background[data-weather="partly-cloudy"] { --cloud-opacity: 0.35; } -.sky-background[data-weather="cloudy"] { --cloud-opacity: 0.62; --sky-top: #7f95ad; --sky-mid: #a8b8c8; } -.sky-background[data-weather="fog"] { --cloud-opacity: 0.28; --fog-opacity: 0.72; } -.sky-background[data-weather="rain"] { --cloud-opacity: 0.72; --precip-opacity: 0.55; --sky-top: #4f6178; --sky-mid: #6d7f93; --sky-bottom: #8fa0b0; } -.sky-background[data-weather="snow"] { --cloud-opacity: 0.55; --precip-opacity: 0.65; --sky-top: #b7c8d8; --sky-mid: #d4e0ea; --sky-bottom: #edf3f8; } -.sky-background[data-weather="storm"] { --cloud-opacity: 0.85; --precip-opacity: 0.75; --sky-top: #2a3344; --sky-mid: #3d4658; --sky-bottom: #566072; } - -[data-theme="dark"] .glass, -:root:not([data-theme="light"]) .glass { - background: rgba(10, 15, 26, 0.55); -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .glass { - background: rgba(10, 15, 26, 0.55); - } -} - -.weather-chip { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 7px 12px; - border-radius: var(--radius-full); - background: var(--surface); - border: 1px solid var(--border); - backdrop-filter: blur(12px); - font-size: 0.84rem; - font-weight: 600; - color: var(--text-secondary); -} - -.weather-icon { - font-size: 1rem; - line-height: 1; -} - -@keyframes cloud-drift { - from { transform: translateX(-12vw); } - to { transform: translateX(12vw); } -} - -@keyframes orb-float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(10px); } -} - -@keyframes twinkle { - from { opacity: calc(var(--star-opacity) * 0.7); } - to { opacity: var(--star-opacity); } -} - -@keyframes rain-fall { - from { transform: translateY(-10%); } - to { transform: translateY(10%); } -} - -@keyframes snow-fall { - from { transform: translateY(-8%); } - to { transform: translateY(8%); } + --sky-top: #3d2d68; --sky-mid: #d86b4d; --sky-bottom: #ffb27a; } +.sky-background[data-time="night"] .sky-orb { left: 16%; top: 8%; width: 64px; height: 64px; } +.sky-background[data-weather="partly-cloudy"] { --cloud-opacity: 0.32; } +.sky-background[data-weather="cloudy"] { --cloud-opacity: 0.58; } +.sky-background[data-weather="fog"] { --cloud-opacity: 0.24; --fog-opacity: 0.68; } +.sky-background[data-weather="rain"] { --cloud-opacity: 0.68; --precip-opacity: 0.5; } +.sky-background[data-weather="snow"] { --cloud-opacity: 0.5; --precip-opacity: 0.6; } +.sky-background[data-weather="storm"] { --cloud-opacity: 0.8; --precip-opacity: 0.7; } .page { position: relative; z-index: 1; - max-width: 1240px; + max-width: 1280px; margin: 0 auto; - padding: 28px 20px 40px; + padding: 24px 20px 48px; } .glass { background: var(--surface); border: 1px solid var(--border); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); box-shadow: var(--shadow-md); } .panel { border-radius: var(--radius-lg); - padding: 22px; - transition: background-color var(--transition), border-color var(--transition), box-shadow var(--transition); + padding: 24px; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.panel-accent::before { + content: ""; + display: block; + height: 3px; + margin: -24px -24px 20px; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--fl-gradient-linear); } .header, @@ -378,13 +361,11 @@ body { .header { align-items: center; justify-content: space-between; - margin-bottom: 22px; - padding: 18px 22px; + margin-bottom: 20px; + padding: 16px 24px; } -.brand { - align-items: center; -} +.brand { align-items: center; } .brand-mark, .panel-icon, @@ -397,8 +378,9 @@ body { .brand-mark { width: 48px; height: 48px; - background: var(--primary-soft); - color: var(--primary); + background: var(--fl-gradient); + color: #fff; + box-shadow: 0 4px 16px rgba(112, 0, 255, 0.28); } .brand-mark svg, @@ -411,120 +393,128 @@ body { height: 20px; } -.eyebrow { +.eyebrow, +.section-tag { margin: 0 0 4px; color: var(--text-muted); - font-size: 0.78rem; + font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.1em; + letter-spacing: 0.12em; } h1 { margin: 0; - font-size: clamp(1.5rem, 2vw, 1.9rem); + font-size: clamp(1.35rem, 2vw, 1.75rem); font-weight: 700; - letter-spacing: -0.03em; + letter-spacing: -0.02em; + line-height: 1.2; } h2 { margin: 0; - font-size: 1.1rem; - font-weight: 700; - letter-spacing: -0.02em; + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.33; } .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 18px; + gap: 16px; } -.activity-panel { - grid-column: 1 / -1; -} +.activity-panel { grid-column: 1 / -1; } .panel-header { align-items: center; justify-content: space-between; - margin-bottom: 18px; + margin-bottom: 20px; } -.panel-heading { - align-items: center; -} +.panel-heading { align-items: center; } .panel-heading.compact h2 + .muted, .panel-heading.compact p.muted { margin: 2px 0 0; - font-size: 0.88rem; + font-size: 14px; } .panel-icon { - width: 42px; - height: 42px; + width: 40px; + height: 40px; flex-shrink: 0; - background: var(--surface-muted); - color: var(--primary); + background: var(--accent-soft); + color: var(--accent); border: 1px solid var(--border); } -.status-row { - margin-bottom: 18px; -} +.status-row { margin-bottom: 20px; } +.kpi-card, .status-card { + position: relative; display: flex; - align-items: center; - gap: 12px; + align-items: flex-start; + gap: 14px; flex: 1; min-width: 0; background: var(--surface-strong); border: 1px solid var(--border); border-radius: var(--radius-md); - padding: 14px; - transition: border-color var(--transition), transform var(--transition); + padding: 16px; + overflow: hidden; + transition: border-color var(--transition), transform var(--transition), box-shadow var(--transition); +} + +.kpi-card::before, +.status-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--kpi-accent, var(--fl-blue)); } +.kpi-card:hover, .status-card:hover { - transform: translateY(-1px); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); } .status-icon { - width: 40px; - height: 40px; + width: 36px; + height: 36px; flex-shrink: 0; background: var(--neutral-soft); color: var(--text-secondary); } .label { - margin: 0 0 4px; + margin: 0 0 6px; color: var(--text-muted); - font-size: 0.8rem; + font-size: 11px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; } .value { margin: 0; - font-size: 1.15rem; + font-size: 28px; font-weight: 700; - letter-spacing: -0.02em; + letter-spacing: -0.03em; + line-height: 1.1; } -.muted { - color: var(--text-muted); -} +.muted { color: var(--text-muted); } -.btn, -.select, -input, -.icon-btn { - font: inherit; -} +.btn, .select, input, .icon-btn { font: inherit; } -.btn, -.icon-btn { +.btn, .icon-btn { display: inline-flex; align-items: center; justify-content: center; @@ -532,50 +522,48 @@ input, border: 0; border-radius: var(--radius-sm); cursor: pointer; - transition: background-color var(--transition), color var(--transition), transform var(--transition), opacity var(--transition); + transition: transform var(--transition), box-shadow var(--transition), opacity var(--transition); } .btn { - padding: 11px 16px; + padding: 10px 18px; + font-size: 14px; font-weight: 600; + letter-spacing: 0.01em; } .btn:hover:not(:disabled), -.icon-btn:hover { - transform: translateY(-1px); -} +.icon-btn:hover { transform: translateY(-1px); } .btn-primary { - background: var(--primary); - color: white; + background: var(--fl-gradient-linear); + color: #fff; + box-shadow: 0 4px 14px rgba(28, 99, 236, 0.32); } .btn-primary:hover:not(:disabled) { - background: var(--primary-hover); + box-shadow: 0 6px 20px rgba(112, 0, 255, 0.36); } .btn-secondary { background: var(--surface-muted); color: var(--text); - border: 1px solid var(--border); + border: 1px solid var(--border-strong); } -.btn:disabled { - opacity: 0.45; - cursor: not-allowed; -} +.btn:disabled { opacity: 0.4; cursor: not-allowed; } .theme-switch { display: inline-flex; - padding: 4px; + padding: 3px; border-radius: var(--radius-full); background: var(--surface-muted); border: 1px solid var(--border); } .icon-btn { - width: 36px; - height: 36px; + width: 34px; + height: 34px; padding: 0; border-radius: var(--radius-full); background: transparent; @@ -588,147 +576,82 @@ input, box-shadow: var(--shadow-sm); } -.select, -input { +.select, input { width: 100%; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--input-bg); color: var(--text); - padding: 11px 12px; + padding: 10px 12px; + font-size: 14px; transition: border-color var(--transition), box-shadow var(--transition); } -.select:focus, -input:focus { +.select:focus, input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); } -.select { - width: auto; - min-width: 180px; -} +.select { width: auto; min-width: 180px; } -.stack { - display: grid; - gap: 14px; -} - -label { - display: grid; - gap: 8px; -} - -label span { - font-size: 0.88rem; - font-weight: 600; - color: var(--text-secondary); -} +.stack { display: grid; gap: 14px; } +label { display: grid; gap: 6px; } +label span { font-size: 14px; font-weight: 600; color: var(--text-secondary); } -.pill { +.pill, .weather-chip { display: inline-flex; align-items: center; gap: 8px; border-radius: var(--radius-full); - padding: 7px 12px; - font-size: 0.84rem; + padding: 6px 12px; + font-size: 12px; font-weight: 600; - border: 1px solid transparent; -} - -.pill-dot, -.meta-dot, -.live-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: currentColor; -} - -.pill-success { - background: var(--success-soft); - color: var(--success); - border-color: rgba(15, 159, 110, 0.12); -} - -.pill-warning { - background: var(--warning-soft); - color: var(--warning); -} - -.pill-danger { - background: var(--danger-soft); - color: var(--danger); -} - -.pill-neutral { - background: var(--neutral-soft); - color: var(--text-muted); -} - -.status-card.locked { - border-color: rgba(15, 159, 110, 0.28); -} - -.status-card.locked .status-icon { - background: var(--success-soft); - color: var(--success); -} - -.status-card.unlocked { - border-color: rgba(217, 119, 6, 0.28); -} - -.status-card.unlocked .status-icon { - background: var(--warning-soft); - color: var(--warning); -} - -.status-card.open { - border-color: rgba(220, 38, 38, 0.28); -} - -.status-card.open .status-icon { - background: var(--danger-soft); - color: var(--danger); + border: 1px solid var(--border); } -.status-card.closed { - border-color: rgba(15, 159, 110, 0.28); +.weather-chip { + background: var(--surface-strong); + color: var(--text-secondary); } -.status-card.closed .status-icon { - background: var(--success-soft); - color: var(--success); +.pill-dot, .meta-dot, .live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; } -.actions { - margin-bottom: 14px; -} +.pill-success { background: var(--success-soft); color: var(--success); border-color: transparent; } +.pill-warning { background: var(--warning-soft); color: var(--warning); border-color: transparent; } +.pill-danger { background: var(--danger-soft); color: var(--danger); border-color: transparent; } +.pill-neutral { background: var(--neutral-soft); color: var(--text-muted); border-color: transparent; } -.meta { - flex-wrap: wrap; - padding-top: 4px; -} +.status-card.locked, .kpi-card.locked { --kpi-accent: var(--fl-green); } +.status-card.locked .status-icon { background: var(--success-soft); color: var(--success); } +.status-card.unlocked, .kpi-card.unlocked { --kpi-accent: var(--warning); } +.status-card.unlocked .status-icon { background: var(--warning-soft); color: var(--warning); } +.status-card.open, .kpi-card.open { --kpi-accent: var(--danger); } +.status-card.open .status-icon { background: var(--danger-soft); color: var(--danger); } +.status-card.closed, .kpi-card.closed { --kpi-accent: var(--fl-blue); } +.status-card.closed .status-icon { background: var(--primary-soft); color: var(--primary); } +.actions { margin-bottom: 16px; } +.meta { flex-wrap: wrap; padding-top: 4px; } .meta-item { display: inline-flex; align-items: center; gap: 8px; margin: 0; - font-size: 0.88rem; + font-size: 13px; } .meta-dot.online { - background: var(--success); - box-shadow: 0 0 0 4px var(--success-soft); + background: var(--fl-green); + box-shadow: 0 0 0 3px var(--success-soft); } -.meta-dot.offline { - background: var(--danger); -} +.meta-dot.offline { background: var(--danger); } .callout { display: flex; @@ -737,9 +660,10 @@ label span { margin-bottom: 12px; padding: 12px 14px; border-radius: var(--radius-sm); - background: var(--neutral-soft); + background: var(--accent-soft); + border: 1px solid var(--border); color: var(--text-secondary); - font-size: 0.92rem; + font-size: 14px; } .camera-frame { @@ -770,34 +694,29 @@ label span { display: inline-flex; align-items: center; gap: 8px; - padding: 6px 10px; + padding: 5px 10px; border-radius: var(--radius-full); background: var(--overlay); backdrop-filter: blur(8px); color: var(--text); - font-size: 0.78rem; + font-size: 10px; font-weight: 700; - letter-spacing: 0.04em; + letter-spacing: 0.1em; text-transform: uppercase; } .live-dot { background: var(--danger); - box-shadow: 0 0 0 4px var(--danger-soft); + box-shadow: 0 0 0 3px var(--danger-soft); animation: pulse 1.6s infinite; } -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.45; } -} - .activity-list { list-style: none; margin: 0; padding: 0; display: grid; - gap: 10px; + gap: 8px; } .activity-item { @@ -813,7 +732,8 @@ label span { } .activity-item:hover { - transform: translateY(-1px); + transform: translateX(4px); + border-color: var(--border-strong); } .activity-main { @@ -824,8 +744,8 @@ label span { } .activity-icon { - width: 38px; - height: 38px; + width: 36px; + height: 36px; display: grid; place-items: center; border-radius: var(--radius-sm); @@ -834,11 +754,7 @@ label span { color: var(--text-secondary); } -.activity-icon svg { - width: 18px; - height: 18px; -} - +.activity-icon svg { width: 18px; height: 18px; } .activity-icon.lock { background: var(--success-soft); color: var(--success); } .activity-icon.unlock { background: var(--warning-soft); color: var(--warning); } .activity-icon.dooropen { background: var(--danger-soft); color: var(--danger); } @@ -847,52 +763,52 @@ label span { .activity-copy strong { display: block; margin-bottom: 2px; - font-size: 0.96rem; -} - -.activity-copy span { - font-size: 0.86rem; + font-size: 14px; + font-weight: 600; } +.activity-copy span { font-size: 13px; } .activity-time { flex-shrink: 0; - font-size: 0.82rem; + font-size: 12px; color: var(--text-muted); white-space: nowrap; } -.auth-panel { - margin-bottom: 18px; -} +.auth-panel { margin-bottom: 16px; } +.hidden { display: none !important; } -.hidden { - display: none !important; +@keyframes cloud-drift { + from { transform: translateX(-12vw); } + to { transform: translateX(12vw); } +} +@keyframes orb-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(8px); } +} +@keyframes twinkle { + from { opacity: calc(var(--star-opacity) * 0.7); } + to { opacity: var(--star-opacity); } +} +@keyframes rain-fall { + from { transform: translateY(-10%); } + to { transform: translateY(10%); } +} +@keyframes snow-fall { + from { transform: translateY(-8%); } + to { transform: translateY(8%); } +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } } @media (max-width: 900px) { - .grid { - grid-template-columns: 1fr; - } - - .header { - flex-direction: column; - align-items: stretch; - } - - .header-actions { - flex-wrap: wrap; - } - - .status-row { - flex-direction: column; - } - - .activity-item { - flex-direction: column; - align-items: flex-start; - } - - .activity-time { - white-space: normal; - } + .grid { grid-template-columns: 1fr; } + .header { flex-direction: column; align-items: stretch; } + .header-actions { flex-wrap: wrap; } + .status-row { flex-direction: column; } + .activity-item { flex-direction: column; align-items: flex-start; } + .activity-time { white-space: normal; } + .value { font-size: 24px; } } diff --git a/src/yale_lock/templates/index.html b/src/yale_lock/templates/index.html index 1496f81..2b70152 100644 --- a/src/yale_lock/templates/index.html +++ b/src/yale_lock/templates/index.html @@ -4,9 +4,6 @@ Home Dashboard - - - @@ -22,6 +19,8 @@
      +
      +
      @@ -35,8 +34,8 @@
      -

      Home Dashboard

      -

      Yale Lock & UniFi Camera

      +

      Operations Dashboard

      +

      Home Security

      @@ -111,7 +110,7 @@

      Yale Sign In

      -
      +
      +

      Yale Lock

      No lock selected

      @@ -128,7 +128,7 @@

      Yale Lock

      -
      +
      -
      +
      -
      +
      -
      +
      +

      UniFi Camera

      Live snapshot feed

      @@ -232,7 +233,7 @@

      UniFi Camera

      -
      +
      +

      Recent Activity

      Lock and door events

      diff --git a/tests/test_yale_lock.py b/tests/test_yale_lock.py index 1de578d..0855a84 100644 --- a/tests/test_yale_lock.py +++ b/tests/test_yale_lock.py @@ -57,5 +57,5 @@ def test_status_endpoint(test_client: TestClient) -> None: def test_index_page(test_client: TestClient) -> None: response = test_client.get("/") assert response.status_code == 200 - assert "Home Dashboard" in response.text + assert "Home Security" in response.text assert "UniFi Camera" in response.text From d38d29c5ce399835c16460830745d705701135d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 03:44:06 +0000 Subject: [PATCH 5/8] Major UI overhaul inspired by Fuselab Creative Redesign with airy glass tiles, bento layout, Liter typography, neon accent CTAs, circular lock status ring, timeline activity feed, SVG weather icons, and soft animated gradient ambient background. Co-authored-by: Nathan Faggian --- src/yale_lock/static/app.js | 291 ++++---- src/yale_lock/static/styles.css | 1021 ++++++++++++---------------- src/yale_lock/templates/index.html | 324 ++++----- 3 files changed, 705 insertions(+), 931 deletions(-) diff --git a/src/yale_lock/static/app.js b/src/yale_lock/static/app.js index 17da61e..fe4c7fe 100644 --- a/src/yale_lock/static/app.js +++ b/src/yale_lock/static/app.js @@ -4,7 +4,7 @@ const weatherRefreshMs = Math.max(60000, (config.weatherRefreshSeconds || 900) * const THEME_STORAGE_KEY = "home-dashboard-theme"; const els = { - skyBackground: document.getElementById("sky-background"), + ambient: document.getElementById("ambient"), weatherChip: document.getElementById("weather-chip"), weatherIcon: document.getElementById("weather-icon"), weatherLabel: document.getElementById("weather-label"), @@ -19,10 +19,10 @@ const els = { verifyCode: document.getElementById("verify-code"), lockName: document.getElementById("lock-name"), lockStatus: document.getElementById("lock-status"), + lockRing: document.getElementById("lock-ring"), doorStatus: document.getElementById("door-status"), - batteryLevel: document.getElementById("battery-level"), - lockStatusCard: document.getElementById("lock-status-card"), doorStatusCard: document.getElementById("door-status-card"), + batteryLevel: document.getElementById("battery-level"), bridgeStatus: document.getElementById("bridge-status"), bridgeDot: document.getElementById("bridge-dot"), lockUpdated: document.getElementById("lock-updated"), @@ -30,6 +30,7 @@ const els = { unlockBtn: document.getElementById("unlock-btn"), cameraSelect: document.getElementById("camera-select"), cameraImage: document.getElementById("camera-image"), + cameraPlaceholder: document.getElementById("camera-placeholder"), cameraMessage: document.getElementById("camera-message"), cameraStatus: document.getElementById("camera-status"), activityList: document.getElementById("activity-list"), @@ -42,18 +43,34 @@ let selectedCameraId = null; let snapshotTimer = null; let ambienceTimer = null; let localTimeTimer = null; -let ambienceTimezone = null; - -const weatherIcons = { - clear: "☀️", - "partly-cloudy": "⛅", - cloudy: "☁️", - fog: "🌫️", - rain: "🌧️", - snow: "❄️", - storm: "⛈️", + +const weatherIconSvg = { + clear: ``, + "partly-cloudy": ``, + cloudy: ``, + fog: ``, + rain: ``, + snow: ``, + storm: ``, }; +const activityIcons = { + lock: ``, + unlock: ``, + dooropen: ``, + doorclosed: ``, + default: ``, +}; + +function titleCase(value) { + return String(value || "unknown").replaceAll("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function formatTime(value) { + if (!value) return "—"; + return new Date(value).toLocaleString(); +} + function localTimeOfDay(date = new Date()) { const hour = date.getHours(); if (hour >= 21 || hour < 5) return "night"; @@ -71,48 +88,43 @@ function formatTemperature(value) { function formatAmbienceLabel(ambience) { const temp = formatTemperature(ambience.temperature_c); - const timeLabel = titleCase(ambience.time_of_day); - if (temp && ambience.weather_label) { - return `${temp} · ${ambience.weather_label}`; - } - if (ambience.weather_label) { - return ambience.weather_label; - } - return timeLabel; + if (temp && ambience.weather_label) return `${temp} · ${ambience.weather_label}`; + if (ambience.weather_label) return ambience.weather_label; + return titleCase(ambience.time_of_day); +} + +function setWeatherIcon(weather) { + if (!els.weatherIcon) return; + els.weatherIcon.innerHTML = weatherIconSvg[weather] || weatherIconSvg.clear; } function applyAmbience(ambience) { const time = ambience?.time_of_day || localTimeOfDay(); const weather = ambience?.weather || "clear"; - if (els.skyBackground) { - els.skyBackground.dataset.time = time; - els.skyBackground.dataset.weather = weather; + if (els.ambient) { + els.ambient.dataset.time = time; + els.ambient.dataset.weather = weather; } - if (els.weatherIcon) { - els.weatherIcon.textContent = weatherIcons[weather] || "🌤️"; - } + setWeatherIcon(weather); if (els.weatherLabel) { if (ambience?.message && !ambience.configured) { - els.weatherLabel.textContent = `${titleCase(time)} · Local time`; + els.weatherLabel.textContent = `${titleCase(time)} · Local`; } else { - els.weatherLabel.textContent = formatAmbienceLabel(ambience || { time_of_day: time, weather_label: titleCase(time) }); + els.weatherLabel.textContent = formatAmbienceLabel( + ambience || { time_of_day: time, weather_label: titleCase(time) }, + ); } } - - if (ambience?.timezone) { - ambienceTimezone = ambience.timezone; - } } function updateLocalAmbienceTime() { - const now = new Date(); applyAmbience({ - time_of_day: localTimeOfDay(now), - weather: els.skyBackground?.dataset.weather || "clear", - weather_label: els.weatherLabel?.textContent || titleCase(localTimeOfDay(now)), + time_of_day: localTimeOfDay(), + weather: els.ambient?.dataset.weather || "clear", + weather_label: els.weatherLabel?.textContent || titleCase(localTimeOfDay()), configured: config.weatherConfigured, }); } @@ -133,52 +145,44 @@ function startAmbienceLoop() { localTimeTimer = setInterval(updateLocalAmbienceTime, 60000); } -const activityIcons = { - lock: ``, - unlock: ``, - dooropen: ``, - doorclosed: ``, - default: ``, -}; - -function titleCase(value) { - return String(value || "unknown").replaceAll("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - -function formatTime(value) { - if (!value) return "—"; - return new Date(value).toLocaleString(); -} - function activityIconClass(action) { if (!action) return "default"; if (action.includes("lock") && !action.includes("unlock")) return "lock"; if (action.includes("unlock")) return "unlock"; if (action.includes("dooropen") || action.includes("door_open")) return "dooropen"; - if (action.includes("doorclose") || action.includes("door_close") || action.includes("doorclosed")) { + if (action.includes("doorclose") || action.includes("door_closed") || action.includes("doorclosed")) { return "doorclosed"; } return "default"; } function setPill(text, tone = "neutral") { - els.connectionPill.innerHTML = `${text}`; - els.connectionPill.className = `pill pill-${tone}`; + if (!els.connectionPill) return; + const textEl = els.connectionPill.querySelector(".chip__text"); + if (textEl) textEl.textContent = text; + els.connectionPill.className = "chip chip--status"; + if (tone === "success") els.connectionPill.classList.add("is-success"); + if (tone === "warning") els.connectionPill.classList.add("is-warning"); + if (tone === "danger") els.connectionPill.classList.add("is-danger"); +} + +function applyLockRing(lockStatus, doorStatus) { + if (!els.lockRing) return; + const state = lockStatus === "unknown" ? doorStatus : lockStatus; + els.lockRing.dataset.state = state || "unknown"; } -function applyStatusCard(card, value) { - card.classList.remove("locked", "unlocked", "open", "closed"); - if (value) card.classList.add(value); +function applyDoorMetric(doorStatus) { + if (!els.doorStatusCard) return; + els.doorStatusCard.classList.remove("open", "closed"); + if (doorStatus === "open") els.doorStatusCard.classList.add("open"); + if (doorStatus === "closed") els.doorStatusCard.classList.add("closed"); } function setCameraMessage(text) { - const messageSpan = els.cameraMessage.querySelector("span"); - if (messageSpan) { - messageSpan.textContent = text; - } else { - els.cameraMessage.textContent = text; - } - els.cameraMessage.classList.remove("hidden"); + const messageSpan = els.cameraMessage?.querySelector("span"); + if (messageSpan) messageSpan.textContent = text; + els.cameraMessage?.classList.remove("hidden"); } function applyTheme(mode) { @@ -186,18 +190,17 @@ function applyTheme(mode) { localStorage.setItem(THEME_STORAGE_KEY, mode); for (const button of [els.themeLight, els.themeSystem, els.themeDark]) { - button?.classList.remove("active"); + button?.classList.remove("is-active"); } - if (mode === "light") els.themeLight?.classList.add("active"); - if (mode === "system") els.themeSystem?.classList.add("active"); - if (mode === "dark") els.themeDark?.classList.add("active"); + if (mode === "light") els.themeLight?.classList.add("is-active"); + if (mode === "system") els.themeSystem?.classList.add("is-active"); + if (mode === "dark") els.themeDark?.classList.add("is-active"); } function initTheme() { const saved = localStorage.getItem(THEME_STORAGE_KEY) || "system"; applyTheme(saved); - els.themeLight?.addEventListener("click", () => applyTheme("light")); els.themeSystem?.addEventListener("click", () => applyTheme("system")); els.themeDark?.addEventListener("click", () => applyTheme("dark")); @@ -213,48 +216,42 @@ async function api(path, options = {}) { throw new Error(detail || response.statusText); } const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - return response.json(); - } + if (contentType.includes("application/json")) return response.json(); return response; } function renderActivities(activities) { + if (!els.activityList) return; els.activityList.innerHTML = ""; + if (!activities || activities.length === 0) { - els.activityList.innerHTML = ` -
    • -
      -
      ${activityIcons.default}
      -
      No recent activityEvents will appear here
      -
      -
    • `; + els.activityList.innerHTML = `
    • No events yet — lock and door activity will appear here.
    • `; return; } - for (const activity of activities) { + activities.forEach((activity, index) => { const iconClass = activityIconClass(activity.action); const item = document.createElement("li"); - item.className = "activity-item"; + item.className = "timeline-item"; + item.style.animationDelay = `${index * 0.04}s`; item.innerHTML = ` -
      -
      ${activityIcons[iconClass] || activityIcons.default}
      -
      - ${activity.label} - ${activity.operator || activity.device_name || "System"} -
      +
      ${activityIcons[iconClass] || activityIcons.default}
      +
      + ${activity.label} + ${activity.operator || activity.device_name || "System"}
      - ${formatTime(activity.timestamp)} + `; els.activityList.appendChild(item); - } + }); } function renderCameraOptions(cameras, selectedId) { + if (!els.cameraSelect) return; els.cameraSelect.innerHTML = ""; if (!cameras || cameras.length === 0) { const option = document.createElement("option"); - option.textContent = "No cameras found"; + option.textContent = "No cameras"; els.cameraSelect.appendChild(option); els.cameraSelect.disabled = true; return; @@ -270,16 +267,21 @@ function renderCameraOptions(cameras, selectedId) { } } +function showCameraFeed(show) { + if (els.cameraImage) els.cameraImage.hidden = !show; + if (els.cameraPlaceholder) els.cameraPlaceholder.hidden = show; +} + function updateCameraSnapshot() { if (!selectedCameraId) return; const url = `/api/camera/snapshot?camera_id=${encodeURIComponent(selectedCameraId)}&t=${Date.now()}`; els.cameraImage.onload = () => { - els.cameraImage.hidden = false; - els.cameraMessage.classList.add("hidden"); + showCameraFeed(true); + els.cameraMessage?.classList.add("hidden"); }; els.cameraImage.onerror = () => { - els.cameraImage.hidden = true; - setCameraMessage("Unable to load camera snapshot"); + showCameraFeed(false); + setCameraMessage("Unable to load snapshot"); }; els.cameraImage.src = url; } @@ -296,68 +298,71 @@ function renderStatus(status) { const needsAuth = authState !== "authenticated" && config.yaleConfigured; if (needsAuth || authState === "requires_validation") { - els.authPanel.classList.remove("hidden"); - els.authMessage.textContent = status.auth_message || "Sign in to control your Yale lock."; - els.verifyForm.classList.toggle("hidden", authState !== "requires_validation"); - els.loginForm.classList.toggle("hidden", authState === "requires_validation"); + els.authPanel?.classList.remove("hidden"); + if (els.authMessage) els.authMessage.textContent = status.auth_message || "Sign in to control your Yale lock."; + els.verifyForm?.classList.toggle("hidden", authState !== "requires_validation"); + els.loginForm?.classList.toggle("hidden", authState === "requires_validation"); } else { - els.authPanel.classList.add("hidden"); + els.authPanel?.classList.add("hidden"); } - if (status.authenticated) { - setPill("Yale connected", "success"); - } else if (config.yaleConfigured) { - setPill("Yale auth required", "warning"); - } else { - setPill("Yale not configured", "neutral"); - } + if (status.authenticated) setPill("Yale connected", "success"); + else if (config.yaleConfigured) setPill("Sign in required", "warning"); + else setPill("Not configured", "neutral"); const lock = status.lock; - els.lockName.textContent = lock ? lock.name : "No lock selected"; - els.lockStatus.textContent = titleCase(status.lock_status); - els.doorStatus.textContent = titleCase(status.door_status); - els.batteryLevel.textContent = lock && lock.battery_level != null ? `${lock.battery_level}%` : "—"; - - if (lock) { - const bridgeOnline = lock.bridge_online; - els.bridgeStatus.innerHTML = `Bridge: ${bridgeOnline ? "Online" : "Offline"}`; - } else { - els.bridgeStatus.innerHTML = `Bridge: —`; + if (els.lockName) els.lockName.textContent = lock ? lock.name : "No lock selected"; + + const lockLabel = titleCase(status.lock_status); + if (els.lockStatus) els.lockStatus.textContent = lockLabel; + if (els.doorStatus) els.doorStatus.textContent = titleCase(status.door_status); + if (els.batteryLevel) { + els.batteryLevel.textContent = lock && lock.battery_level != null ? `${lock.battery_level}%` : "—"; } - els.lockUpdated.textContent = `Updated: ${formatTime(status.lock_status_updated_at || status.updated_at)}`; + applyLockRing(status.lock_status, status.door_status); + applyDoorMetric(status.door_status); + + if (lock && els.bridgeStatus) { + const online = lock.bridge_online; + els.bridgeStatus.innerHTML = `Bridge ${online ? "online" : "offline"}`; + } else if (els.bridgeStatus) { + els.bridgeStatus.innerHTML = `Bridge —`; + } - applyStatusCard(els.lockStatusCard, status.lock_status); - applyStatusCard(els.doorStatusCard, status.door_status); + if (els.lockUpdated) { + els.lockUpdated.textContent = `Updated ${formatTime(status.lock_status_updated_at || status.updated_at)}`; + } const controlsEnabled = status.authenticated && !!lock; - els.lockBtn.disabled = !controlsEnabled; - els.unlockBtn.disabled = !controlsEnabled; + if (els.lockBtn) els.lockBtn.disabled = !controlsEnabled; + if (els.unlockBtn) els.unlockBtn.disabled = !controlsEnabled; renderActivities(status.activities); const camera = status.camera || {}; if (!config.unifiConfigured) { setCameraMessage("Set UNIFI_HOST, UNIFI_USERNAME, and UNIFI_PASSWORD in .env"); - els.cameraImage.hidden = true; + showCameraFeed(false); return; } if (!camera.connected) { setCameraMessage(camera.message || "UniFi Protect unavailable"); - els.cameraImage.hidden = true; + showCameraFeed(false); } renderCameraOptions(camera.cameras || [], camera.selected_camera_id); selectedCameraId = camera.selected_camera_id || selectedCameraId; const active = (camera.cameras || []).find((item) => item.camera_id === selectedCameraId); - els.cameraStatus.textContent = active - ? `Status: ${active.is_connected ? "Connected" : "Offline"}${active.is_motion_detected ? " · Motion" : ""}` - : "Status: —"; - if (camera.connected && selectedCameraId) { - startSnapshotLoop(); + if (els.cameraStatus) { + els.cameraStatus.textContent = active + ? `${active.is_connected ? "Connected" : "Offline"}${active.is_motion_detected ? " · Motion" : ""}` + : "—"; } + + if (camera.connected && selectedCameraId) startSnapshotLoop(); } async function refreshStatus() { @@ -367,19 +372,15 @@ async function refreshStatus() { function connectEvents() { const source = new EventSource("/api/events"); - source.onmessage = (event) => { - renderStatus(JSON.parse(event.data)); - }; - source.onerror = () => { - setPill("Live updates paused", "warning"); - }; + source.onmessage = (event) => renderStatus(JSON.parse(event.data)); + source.onerror = () => setPill("Updates paused", "warning"); } -els.refreshBtn.addEventListener("click", () => { +els.refreshBtn?.addEventListener("click", () => { refreshStatus().catch((error) => setPill(error.message, "danger")); }); -els.lockBtn.addEventListener("click", async () => { +els.lockBtn?.addEventListener("click", async () => { try { const result = await api("/api/lock", { method: "POST" }); if (result.status) renderStatus(result.status); @@ -388,7 +389,7 @@ els.lockBtn.addEventListener("click", async () => { } }); -els.unlockBtn.addEventListener("click", async () => { +els.unlockBtn?.addEventListener("click", async () => { try { const result = await api("/api/unlock", { method: "POST" }); if (result.status) renderStatus(result.status); @@ -397,7 +398,7 @@ els.unlockBtn.addEventListener("click", async () => { } }); -els.loginForm.addEventListener("submit", async (event) => { +els.loginForm?.addEventListener("submit", async (event) => { event.preventDefault(); try { const status = await api("/api/auth/login", { @@ -410,11 +411,11 @@ els.loginForm.addEventListener("submit", async (event) => { }); renderStatus(status); } catch (error) { - els.authMessage.textContent = error.message; + if (els.authMessage) els.authMessage.textContent = error.message; } }); -els.verifyForm.addEventListener("submit", async (event) => { +els.verifyForm?.addEventListener("submit", async (event) => { event.preventDefault(); try { const status = await api("/api/auth/verify", { @@ -423,11 +424,11 @@ els.verifyForm.addEventListener("submit", async (event) => { }); renderStatus(status); } catch (error) { - els.authMessage.textContent = error.message; + if (els.authMessage) els.authMessage.textContent = error.message; } }); -els.cameraSelect.addEventListener("change", async () => { +els.cameraSelect?.addEventListener("change", async () => { selectedCameraId = els.cameraSelect.value; try { await api(`/api/cameras/${encodeURIComponent(selectedCameraId)}/select`, { method: "POST" }); diff --git a/src/yale_lock/static/styles.css b/src/yale_lock/static/styles.css index 5c9e491..b8bd366 100644 --- a/src/yale_lock/static/styles.css +++ b/src/yale_lock/static/styles.css @@ -1,4 +1,5 @@ -/* Fuselab-inspired design tokens — https://fuselabcreative.com */ +/* Fuselab-inspired dashboard — https://fuselabcreative.com */ + @font-face { font-family: "Liter"; font-style: normal; @@ -6,7 +7,6 @@ font-display: swap; src: url("https://fuselabcreative.com/wp-content/themes/fuselab/assets/fonts/liter/files/liter-regular.woff2") format("woff2"); } - @font-face { font-family: "Liter"; font-style: normal; @@ -14,7 +14,6 @@ font-display: swap; src: url("https://fuselabcreative.com/wp-content/themes/fuselab/assets/fonts/liter/files/liter-semibold.woff2") format("woff2"); } - @font-face { font-family: "Liter"; font-style: normal; @@ -25,156 +24,98 @@ :root { color-scheme: light dark; - - /* Fuselab primitives */ --fl-blue: #1c63ec; - --fl-blue-xr: #2f80ed; - --fl-ultramarine: #7000ff; + --fl-ultra: #7000ff; --fl-green: #1dec86; - --fl-gray-5: #f3f4f5; - --fl-gray-10: #e8e9ec; - --fl-gray-20: #ced0d6; - --fl-gray-40: #9fa3ab; - --fl-gray-60: #70747e; - --fl-gray-80: #424750; - --fl-gray-90: #2b3037; - --fl-gray-100: #091528; - --fl-gradient: radial-gradient(50% 50% at 50% 50%, #4769ff 37.64%, #7000ff 100%); - --fl-gradient-linear: linear-gradient(135deg, #1c63ec 0%, #7000ff 100%); - - --font: "Liter", ui-sans-serif, system-ui, sans-serif; - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 16px; - --radius-full: 999px; - --transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); - --shadow-sm: 0 1px 2px rgba(9, 21, 40, 0.06); - --shadow-md: 0 8px 24px rgba(9, 21, 40, 0.08); - --shadow-lg: 0 20px 48px rgba(9, 21, 40, 0.14); -} - -:root, -[data-theme="light"] { - --bg: var(--fl-gray-5); - --text: var(--fl-gray-100); - --text-secondary: var(--fl-gray-80); - --text-muted: var(--fl-gray-60); - --surface: rgba(255, 255, 255, 0.88); - --surface-strong: #ffffff; - --surface-muted: rgba(255, 255, 255, 0.6); - --surface-elevated: #ffffff; - --border: var(--fl-gray-10); - --border-strong: var(--fl-gray-20); - --primary: var(--fl-blue); - --primary-hover: #1554c7; - --primary-soft: rgba(28, 99, 236, 0.1); - --accent: var(--fl-ultramarine); - --accent-soft: rgba(112, 0, 255, 0.1); + --fl-ink: #091528; + --fl-muted: #70747e; + --fl-line: rgba(9, 21, 40, 0.08); + --fl-surface: rgba(255, 255, 255, 0.78); + --fl-surface-solid: #ffffff; + --fl-shadow: 0 1px 2px rgba(9, 21, 40, 0.04), 0 20px 48px rgba(9, 21, 40, 0.07); + --fl-radius: 24px; + --fl-radius-sm: 14px; + --font: "Liter", system-ui, sans-serif; + --ease: cubic-bezier(0.22, 1, 0.36, 1); + --blob-a: rgba(28, 99, 236, 0.18); + --blob-b: rgba(112, 0, 255, 0.12); + --blob-c: rgba(29, 236, 134, 0.1); + --text: var(--fl-ink); + --text-muted: var(--fl-muted); + --surface: var(--fl-surface); + --surface-solid: var(--fl-surface-solid); + --border: var(--fl-line); + --accent: var(--fl-green); + --accent-text: #053d24; + --ring-track: rgba(9, 21, 40, 0.08); + --ring-color: var(--fl-blue); --success: #0a9f62; - --success-soft: rgba(29, 236, 134, 0.14); --warning: #c77700; - --warning-soft: rgba(199, 119, 0, 0.12); --danger: #d92d20; - --danger-soft: rgba(217, 45, 32, 0.1); - --neutral-soft: rgba(112, 116, 126, 0.1); - --camera-bg: var(--fl-gray-100); - --input-bg: #ffffff; - --overlay: rgba(255, 255, 255, 0.82); - --mesh-a: rgba(112, 0, 255, 0.08); - --mesh-b: rgba(28, 99, 236, 0.1); - --grid-line: rgba(9, 21, 40, 0.04); } [data-theme="dark"] { - --bg: var(--fl-gray-100); + --fl-surface: rgba(22, 27, 38, 0.72); + --fl-surface-solid: #1a1f2b; + --fl-line: rgba(255, 255, 255, 0.08); + --fl-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 24px 60px rgba(0, 0, 0, 0.35); --text: #f3f4f5; - --text-secondary: var(--fl-gray-20); - --text-muted: var(--fl-gray-40); - --surface: rgba(33, 37, 41, 0.72); - --surface-strong: rgba(43, 48, 55, 0.95); - --surface-muted: rgba(255, 255, 255, 0.04); - --surface-elevated: #2b3037; - --border: rgba(255, 255, 255, 0.08); - --border-strong: rgba(255, 255, 255, 0.12); - --primary: var(--fl-blue-xr); - --primary-hover: #4d95f0; - --primary-soft: rgba(47, 128, 237, 0.16); - --accent: #8f3dff; - --accent-soft: rgba(112, 0, 255, 0.18); - --success: var(--fl-green); - --success-soft: rgba(29, 236, 134, 0.14); - --warning: #ffb020; - --warning-soft: rgba(255, 176, 32, 0.14); - --danger: #ff6b6b; - --danger-soft: rgba(255, 107, 107, 0.14); - --neutral-soft: rgba(159, 163, 171, 0.12); - --camera-bg: #050a14; - --input-bg: rgba(255, 255, 255, 0.05); - --overlay: rgba(9, 21, 40, 0.72); - --mesh-a: rgba(112, 0, 255, 0.22); - --mesh-b: rgba(28, 99, 236, 0.18); - --grid-line: rgba(255, 255, 255, 0.04); + --text-muted: #9fa3ab; + --surface: var(--fl-surface); + --surface-solid: var(--fl-surface-solid); + --border: var(--fl-line); + --accent-text: #042a18; + --ring-track: rgba(255, 255, 255, 0.08); + --blob-a: rgba(47, 128, 237, 0.22); + --blob-b: rgba(143, 61, 255, 0.18); + --blob-c: rgba(29, 236, 134, 0.08); } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { - --bg: var(--fl-gray-100); + --fl-surface: rgba(22, 27, 38, 0.72); + --fl-surface-solid: #1a1f2b; + --fl-line: rgba(255, 255, 255, 0.08); + --fl-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 24px 60px rgba(0, 0, 0, 0.35); --text: #f3f4f5; - --text-secondary: var(--fl-gray-20); - --text-muted: var(--fl-gray-40); - --surface: rgba(33, 37, 41, 0.72); - --surface-strong: rgba(43, 48, 55, 0.95); - --surface-muted: rgba(255, 255, 255, 0.04); - --surface-elevated: #2b3037; - --border: rgba(255, 255, 255, 0.08); - --border-strong: rgba(255, 255, 255, 0.12); - --primary: var(--fl-blue-xr); - --primary-hover: #4d95f0; - --primary-soft: rgba(47, 128, 237, 0.16); - --accent: #8f3dff; - --accent-soft: rgba(112, 0, 255, 0.18); - --success: var(--fl-green); - --success-soft: rgba(29, 236, 134, 0.14); - --warning: #ffb020; - --warning-soft: rgba(255, 176, 32, 0.14); - --danger: #ff6b6b; - --danger-soft: rgba(255, 107, 107, 0.14); - --neutral-soft: rgba(159, 163, 171, 0.12); - --camera-bg: #050a14; - --input-bg: rgba(255, 255, 255, 0.05); - --overlay: rgba(9, 21, 40, 0.72); - --mesh-a: rgba(112, 0, 255, 0.22); - --mesh-b: rgba(28, 99, 236, 0.18); - --grid-line: rgba(255, 255, 255, 0.04); + --text-muted: #9fa3ab; + --surface: var(--fl-surface); + --surface-solid: var(--fl-surface-solid); + --border: var(--fl-line); + --accent-text: #042a18; + --ring-track: rgba(255, 255, 255, 0.08); + --blob-a: rgba(47, 128, 237, 0.22); + --blob-b: rgba(143, 61, 255, 0.18); + --blob-c: rgba(29, 236, 134, 0.08); } } -* { box-sizing: border-box; } +*, *::before, *::after { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; + min-height: 100vh; font-family: var(--font); font-size: 16px; line-height: 1.5; - background: transparent; color: var(--text); - min-height: 100vh; + background: #eef0f4; -webkit-font-smoothing: antialiased; } -/* Dynamic sky + Fuselab mesh */ -.sky-background { - --sky-top: #8ec5ff; - --sky-mid: #c8e4ff; - --sky-bottom: #eef6ff; - --orb-color: #ffd76a; - --orb-glow: rgba(255, 215, 106, 0.35); - --cloud-opacity: 0.14; - --fog-opacity: 0; - --precip-opacity: 0; - --star-opacity: 0; +[data-theme="dark"] body, +:root:not([data-theme="light"]) body { + background: #0c1018; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) body { background: #0c1018; } +} + +/* Ambient */ +.ambient { position: fixed; inset: 0; z-index: 0; @@ -182,633 +123,547 @@ body { pointer-events: none; } -.sky-gradient, -.sky-stars, -.sky-orb, -.sky-clouds, -.sky-fog, -.sky-precip, -.fuselab-mesh, -.fuselab-grid { +.ambient__base { position: absolute; inset: 0; + background: linear-gradient(165deg, #f7f8fa 0%, #eef1f6 45%, #e8ecf4 100%); } -.sky-gradient { - background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-mid) 42%, var(--sky-bottom) 100%); - transition: background 1.4s ease; - opacity: 0.55; -} - -[data-theme="dark"] .sky-gradient, -:root:not([data-theme="light"]) .sky-gradient { - opacity: 0.35; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) .sky-gradient { opacity: 0.35; } -} - -.fuselab-mesh { - background: - radial-gradient(ellipse 70% 55% at 15% 0%, var(--mesh-a), transparent 60%), - radial-gradient(ellipse 55% 45% at 85% 10%, var(--mesh-b), transparent 55%), - radial-gradient(ellipse 40% 35% at 50% 100%, var(--mesh-a), transparent 50%); -} - -.fuselab-grid { - background-image: - linear-gradient(var(--grid-line) 1px, transparent 1px), - linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); - background-size: 48px 48px; - mask-image: linear-gradient(180deg, black, transparent 85%); -} - -.sky-stars { - opacity: var(--star-opacity); - background-image: - radial-gradient(1px 1px at 20px 30px, rgba(255, 255, 255, 0.9), transparent), - radial-gradient(1px 1px at 120px 80px, rgba(255, 255, 255, 0.7), transparent), - radial-gradient(1.5px 1.5px at 240px 140px, rgba(255, 255, 255, 0.8), transparent); - background-size: 320px 180px; - animation: twinkle 8s ease-in-out infinite alternate; +[data-theme="dark"] .ambient__base, +:root:not([data-theme="light"]) .ambient__base { + background: linear-gradient(165deg, #0e121c 0%, #0a0f18 50%, #080c14 100%); } -.sky-orb { - width: 100px; - height: 100px; - left: 78%; - top: 10%; - border-radius: 50%; - background: radial-gradient(circle at 35% 35%, #fff8dc 0%, var(--orb-color) 55%, transparent 100%); - box-shadow: 0 0 60px 16px var(--orb-glow); - animation: orb-float 12s ease-in-out infinite; -} - -.sky-clouds { opacity: var(--cloud-opacity); transition: opacity 1s ease; } - -.cloud { +.ambient__blob { position: absolute; - display: block; border-radius: 50%; - background: rgba(255, 255, 255, 0.75); - filter: blur(2px); - box-shadow: 24px 8px 0 -4px rgba(255, 255, 255, 0.65); + filter: blur(90px); + opacity: 0.9; + animation: blob-float 22s var(--ease) infinite; } -.cloud-1 { width: 90px; height: 42px; top: 14%; left: 8%; animation: cloud-drift 48s linear infinite; } -.cloud-2 { width: 120px; height: 52px; top: 22%; left: 42%; animation: cloud-drift 62s linear infinite reverse; } -.cloud-3 { width: 76px; height: 36px; top: 8%; left: 68%; animation: cloud-drift 54s linear infinite; } -.cloud-4 { width: 104px; height: 46px; top: 28%; left: 82%; animation: cloud-drift 70s linear infinite reverse; } - -.sky-fog { - opacity: var(--fog-opacity); - background: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.5) 55%, rgba(255, 255, 255, 0.65) 100%); -} +.ambient__blob--a { width: 55vw; height: 55vw; top: -18%; right: -12%; background: var(--blob-a); } +.ambient__blob--b { width: 45vw; height: 45vw; bottom: -10%; left: -8%; background: var(--blob-b); animation-delay: -8s; } +.ambient__blob--c { width: 30vw; height: 30vw; top: 35%; left: 30%; background: var(--blob-c); animation-delay: -14s; } -.sky-precip { opacity: var(--precip-opacity); overflow: hidden; } - -.sky-precip::before { - content: ""; +.ambient__noise { position: absolute; - inset: -100% 0 0; - background-repeat: repeat; -} - -.sky-background[data-weather="rain"] .sky-precip::before, -.sky-background[data-weather="storm"] .sky-precip::before { - background-image: linear-gradient(180deg, transparent 0%, rgba(255, 255, 255, 0.5) 100%); - background-size: 2px 18px; - animation: rain-fall 0.7s linear infinite; -} - -.sky-background[data-weather="storm"] .sky-precip::before { animation-duration: 0.45s; } - -.sky-background[data-weather="snow"] .sky-precip::before { - background-image: radial-gradient(circle, rgba(255, 255, 255, 0.95) 1px, transparent 1px); - background-size: 18px 18px; - animation: snow-fall 6s linear infinite; + inset: 0; + opacity: 0.035; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } -.sky-background[data-time="night"] { - --sky-top: #0a1028; --sky-mid: #121a38; --sky-bottom: #1a2140; - --orb-color: #e8edf8; --orb-glow: rgba(232, 237, 248, 0.18); --star-opacity: 0.85; -} -.sky-background[data-time="dawn"] { - --sky-top: #5b4b8a; --sky-mid: #f39a7b; --sky-bottom: #ffd9a8; - --orb-color: #ffb36b; --orb-glow: rgba(255, 179, 107, 0.35); -} -.sky-background[data-time="morning"] { - --sky-top: #5eb2ff; --sky-mid: #9ed0ff; --sky-bottom: #e8f4ff; -} -.sky-background[data-time="afternoon"] { - --sky-top: #4aa8ff; --sky-mid: #7ec3ff; --sky-bottom: #d8ecff; -} -.sky-background[data-time="evening"] { - --sky-top: #3d2d68; --sky-mid: #d86b4d; --sky-bottom: #ffb27a; -} -.sky-background[data-time="night"] .sky-orb { left: 16%; top: 8%; width: 64px; height: 64px; } -.sky-background[data-weather="partly-cloudy"] { --cloud-opacity: 0.32; } -.sky-background[data-weather="cloudy"] { --cloud-opacity: 0.58; } -.sky-background[data-weather="fog"] { --cloud-opacity: 0.24; --fog-opacity: 0.68; } -.sky-background[data-weather="rain"] { --cloud-opacity: 0.68; --precip-opacity: 0.5; } -.sky-background[data-weather="snow"] { --cloud-opacity: 0.5; --precip-opacity: 0.6; } -.sky-background[data-weather="storm"] { --cloud-opacity: 0.8; --precip-opacity: 0.7; } +.ambient[data-time="night"] { --blob-a: rgba(47, 80, 180, 0.2); --blob-b: rgba(80, 40, 160, 0.25); } +.ambient[data-time="evening"] { --blob-a: rgba(200, 90, 60, 0.15); --blob-b: rgba(112, 0, 255, 0.14); } +.ambient[data-weather="rain"], +.ambient[data-weather="storm"] { --blob-a: rgba(60, 80, 120, 0.2); --blob-b: rgba(40, 50, 80, 0.22); } +.ambient[data-weather="snow"] { --blob-b: rgba(200, 220, 255, 0.2); } -.page { +/* App shell */ +.app { position: relative; z-index: 1; - max-width: 1280px; + max-width: 1320px; margin: 0 auto; - padding: 24px 20px 48px; + padding: 28px 24px 56px; + animation: rise-in 0.7s var(--ease) both; } -.glass { - background: var(--surface); - border: 1px solid var(--border); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - box-shadow: var(--shadow-md); +@keyframes rise-in { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } } -.panel { - border-radius: var(--radius-lg); - padding: 24px; - transition: border-color var(--transition), box-shadow var(--transition); +@keyframes blob-float { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(2%, 3%) scale(1.04); } + 66% { transform: translate(-2%, 1%) scale(0.98); } } -.panel-accent::before { - content: ""; - display: block; - height: 3px; - margin: -24px -24px 20px; - border-radius: var(--radius-lg) var(--radius-lg) 0 0; - background: var(--fl-gradient-linear); -} - -.header, -.panel-header, -.status-row, -.actions, -.meta, -.header-actions, -.brand, -.panel-heading { +/* Nav */ +.nav { display: flex; - gap: 12px; -} - -.header { align-items: center; justify-content: space-between; - margin-bottom: 20px; - padding: 16px 24px; -} - -.brand { align-items: center; } - -.brand-mark, -.panel-icon, -.status-icon { - display: grid; - place-items: center; - border-radius: var(--radius-md); -} - -.brand-mark { - width: 48px; - height: 48px; - background: var(--fl-gradient); - color: #fff; - box-shadow: 0 4px 16px rgba(112, 0, 255, 0.28); -} - -.brand-mark svg, -.panel-icon svg, -.status-icon svg, -.btn svg, -.icon-btn svg, -.callout svg { - width: 20px; - height: 20px; + gap: 20px; + margin-bottom: 28px; + padding: 14px 18px; + border-radius: var(--fl-radius); + background: var(--surface); + border: 1px solid var(--border); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + box-shadow: var(--fl-shadow); } -.eyebrow, -.section-tag { - margin: 0 0 4px; - color: var(--text-muted); +.nav__brand { display: flex; align-items: center; gap: 14px; } +.nav__logo { width: 44px; height: 44px; flex-shrink: 0; } +.nav__logo svg { width: 100%; height: 100%; display: block; } +.nav__eyebrow { + margin: 0; font-size: 11px; font-weight: 600; + letter-spacing: 0.14em; text-transform: uppercase; - letter-spacing: 0.12em; + color: var(--text-muted); } - -h1 { +.nav__title { margin: 0; - font-size: clamp(1.35rem, 2vw, 1.75rem); + font-size: 18px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; } -h2 { - margin: 0; - font-size: 18px; +.nav__meta, .nav__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + font-size: 13px; font-weight: 600; - letter-spacing: -0.01em; - line-height: 1.33; + border: 1px solid var(--border); + background: var(--surface-solid); +} + +.chip__dot, .foot-dot, .live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.chip__icon { display: flex; width: 18px; height: 18px; } +.chip__icon svg { width: 100%; height: 100%; } + +.chip--status { color: var(--text-muted); } +.chip--status.is-success { color: var(--success); background: rgba(29, 236, 134, 0.1); border-color: transparent; } +.chip--status.is-warning { color: var(--warning); background: rgba(199, 119, 0, 0.1); border-color: transparent; } +.chip--status.is-danger { color: var(--danger); background: rgba(217, 45, 32, 0.08); border-color: transparent; } + +.theme-switch { + display: flex; + padding: 4px; + border-radius: 999px; + background: var(--surface-solid); + border: 1px solid var(--border); } -.grid { +.theme-btn { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; + place-items: center; + width: 34px; + height: 34px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 0.2s var(--ease), color 0.2s var(--ease); } -.activity-panel { grid-column: 1 / -1; } +.theme-btn svg { width: 16px; height: 16px; } +.theme-btn.is-active { background: var(--text); color: var(--surface-solid); } +.theme-btn:hover:not(.is-active) { color: var(--text); } -.panel-header { +/* Buttons */ +.btn { + display: inline-flex; align-items: center; - justify-content: space-between; - margin-bottom: 20px; + justify-content: center; + gap: 8px; + padding: 12px 22px; + border-radius: 999px; + font-family: inherit; + font-size: 14px; + font-weight: 600; + border: none; + cursor: pointer; + transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease), opacity 0.2s; } -.panel-heading { align-items: center; } +.btn svg { width: 16px; height: 16px; } +.btn:hover:not(:disabled) { transform: translateY(-1px); } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } -.panel-heading.compact h2 + .muted, -.panel-heading.compact p.muted { - margin: 2px 0 0; - font-size: 14px; +.btn--accent { + background: var(--fl-green); + color: var(--accent-text); + box-shadow: 0 4px 20px rgba(29, 236, 134, 0.35); } -.panel-icon { - width: 40px; - height: 40px; - flex-shrink: 0; - background: var(--accent-soft); - color: var(--accent); - border: 1px solid var(--border); +.btn--accent:hover:not(:disabled) { + box-shadow: 0 8px 28px rgba(29, 236, 134, 0.45); } -.status-row { margin-bottom: 20px; } +.btn--outline { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} -.kpi-card, -.status-card { - position: relative; - display: flex; - align-items: flex-start; - gap: 14px; - flex: 1; - min-width: 0; - background: var(--surface-strong); +.btn--ghost { + background: var(--surface-solid); + color: var(--text); border: 1px solid var(--border); - border-radius: var(--radius-md); - padding: 16px; - overflow: hidden; - transition: border-color var(--transition), transform var(--transition), box-shadow var(--transition); + padding: 10px 16px; } -.kpi-card::before, -.status-card::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: var(--kpi-accent, var(--fl-blue)); +/* Bento grid */ +.bento { + display: grid; + grid-template-columns: 1fr 1.15fr; + gap: 20px; } -.kpi-card:hover, -.status-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-sm); +.tile { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--fl-radius); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + box-shadow: var(--fl-shadow); + padding: 28px; + animation: rise-in 0.7s var(--ease) both; } -.status-icon { - width: 36px; - height: 36px; - flex-shrink: 0; - background: var(--neutral-soft); - color: var(--text-secondary); +.tile--lock { animation-delay: 0.05s; } +.tile--camera { animation-delay: 0.1s; } +.tile--activity { grid-column: 1 / -1; animation-delay: 0.15s; } +.tile--auth { margin-bottom: 20px; animation-delay: 0s; } + +.tile__head { margin-bottom: 24px; } +.tile__head--row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; } -.label { - margin: 0 0 6px; - color: var(--text-muted); +.tile__tag { + margin: 0 0 8px; font-size: 11px; font-weight: 600; + letter-spacing: 0.12em; text-transform: uppercase; - letter-spacing: 0.08em; + color: var(--fl-blue); } -.value { +[data-theme="dark"] .tile__tag { color: #6ea0ff; } + +.tile__title { margin: 0; - font-size: 28px; + font-size: clamp(1.5rem, 2.5vw, 2rem); font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; } -.muted { color: var(--text-muted); } - -.btn, .select, input, .icon-btn { font: inherit; } - -.btn, .icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border: 0; - border-radius: var(--radius-sm); - cursor: pointer; - transition: transform var(--transition), box-shadow var(--transition), opacity var(--transition); +.tile__sub { + margin: 6px 0 0; + font-size: 15px; + color: var(--text-muted); } -.btn { - padding: 10px 18px; - font-size: 14px; - font-weight: 600; - letter-spacing: 0.01em; +.tile__actions { + display: flex; + gap: 12px; + margin-top: 24px; } -.btn:hover:not(:disabled), -.icon-btn:hover { transform: translateY(-1px); } - -.btn-primary { - background: var(--fl-gradient-linear); - color: #fff; - box-shadow: 0 4px 14px rgba(28, 99, 236, 0.32); +.tile__foot { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); } -.btn-primary:hover:not(:disabled) { - box-shadow: 0 6px 20px rgba(112, 0, 255, 0.36); +.foot-item { + margin: 0; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-muted); } -.btn-secondary { - background: var(--surface-muted); - color: var(--text); - border: 1px solid var(--border-strong); +.foot-dot.is-online { + background: var(--fl-green); + box-shadow: 0 0 0 3px rgba(29, 236, 134, 0.25); } -.btn:disabled { opacity: 0.4; cursor: not-allowed; } - -.theme-switch { - display: inline-flex; - padding: 3px; - border-radius: var(--radius-full); - background: var(--surface-muted); - border: 1px solid var(--border); -} +.foot-dot.is-offline { background: var(--danger); } -.icon-btn { - width: 34px; - height: 34px; - padding: 0; - border-radius: var(--radius-full); - background: transparent; - color: var(--text-muted); -} +/* Lock hero ring */ +.lock-hero { display: grid; gap: 24px; } -.icon-btn.active { - background: var(--surface-strong); - color: var(--text); - box-shadow: var(--shadow-sm); +.lock-ring { + position: relative; + width: min(220px, 100%); + margin: 0 auto; + aspect-ratio: 1; } -.select, input { +.lock-ring__svg { width: 100%; - border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); - background: var(--input-bg); - color: var(--text); - padding: 10px 12px; - font-size: 14px; - transition: border-color var(--transition), box-shadow var(--transition); + height: 100%; + transform: rotate(-90deg); } -.select:focus, input:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px var(--primary-soft); +.lock-ring__track { + fill: none; + stroke: var(--ring-track); + stroke-width: 6; } -.select { width: auto; min-width: 180px; } - -.stack { display: grid; gap: 14px; } -label { display: grid; gap: 6px; } -label span { font-size: 14px; font-weight: 600; color: var(--text-secondary); } +.lock-ring__progress { + fill: none; + stroke: var(--ring-color); + stroke-width: 6; + stroke-linecap: round; + stroke-dasharray: 327; + stroke-dashoffset: 82; + transition: stroke 0.5s var(--ease), stroke-dashoffset 0.6s var(--ease); +} -.pill, .weather-chip { - display: inline-flex; +.lock-ring__core { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; align-items: center; + justify-content: center; gap: 8px; - border-radius: var(--radius-full); - padding: 6px 12px; - font-size: 12px; - font-weight: 600; - border: 1px solid var(--border); } -.weather-chip { - background: var(--surface-strong); - color: var(--text-secondary); +.lock-ring__core svg { width: 32px; height: 32px; color: var(--text-muted); } + +.lock-ring__label { + margin: 0; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; } -.pill-dot, .meta-dot, .live-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: currentColor; +.lock-ring[data-state="locked"] { --ring-color: var(--success); } +.lock-ring[data-state="locked"] .lock-ring__progress { stroke-dashoffset: 0; } +.lock-ring[data-state="unlocked"] { --ring-color: var(--warning); } +.lock-ring[data-state="unlocked"] .lock-ring__progress { stroke-dashoffset: 160; } +.lock-ring[data-state="open"] { --ring-color: var(--danger); } +.lock-ring[data-state="jammed"] { --ring-color: var(--danger); } + +.metric-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; } -.pill-success { background: var(--success-soft); color: var(--success); border-color: transparent; } -.pill-warning { background: var(--warning-soft); color: var(--warning); border-color: transparent; } -.pill-danger { background: var(--danger-soft); color: var(--danger); border-color: transparent; } -.pill-neutral { background: var(--neutral-soft); color: var(--text-muted); border-color: transparent; } +.metric { + padding: 16px 18px; + border-radius: var(--fl-radius-sm); + background: var(--surface-solid); + border: 1px solid var(--border); + transition: border-color 0.25s var(--ease), transform 0.25s var(--ease); +} -.status-card.locked, .kpi-card.locked { --kpi-accent: var(--fl-green); } -.status-card.locked .status-icon { background: var(--success-soft); color: var(--success); } -.status-card.unlocked, .kpi-card.unlocked { --kpi-accent: var(--warning); } -.status-card.unlocked .status-icon { background: var(--warning-soft); color: var(--warning); } -.status-card.open, .kpi-card.open { --kpi-accent: var(--danger); } -.status-card.open .status-icon { background: var(--danger-soft); color: var(--danger); } -.status-card.closed, .kpi-card.closed { --kpi-accent: var(--fl-blue); } -.status-card.closed .status-icon { background: var(--primary-soft); color: var(--primary); } +.metric:hover { transform: translateY(-2px); } -.actions { margin-bottom: 16px; } -.meta { flex-wrap: wrap; padding-top: 4px; } -.meta-item { - display: inline-flex; - align-items: center; - gap: 8px; - margin: 0; - font-size: 13px; +.metric__label { + display: block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; } -.meta-dot.online { - background: var(--fl-green); - box-shadow: 0 0 0 3px var(--success-soft); +.metric__value { + display: block; + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; } -.meta-dot.offline { background: var(--danger); } +.metric.open { border-color: rgba(217, 45, 32, 0.35); color: var(--danger); } +.metric.closed { border-color: rgba(10, 159, 98, 0.35); } -.callout { +/* Camera */ +.notice { display: flex; align-items: center; gap: 10px; - margin-bottom: 12px; - padding: 12px 14px; - border-radius: var(--radius-sm); - background: var(--accent-soft); + padding: 12px 16px; + margin-bottom: 16px; + border-radius: var(--fl-radius-sm); + background: rgba(112, 0, 255, 0.06); border: 1px solid var(--border); - color: var(--text-secondary); font-size: 14px; + color: var(--text-muted); } -.camera-frame { +.notice svg { width: 18px; height: 18px; flex-shrink: 0; color: var(--fl-ultra); } + +.camera { position: relative; - aspect-ratio: 16 / 9; - background: var(--camera-bg); - border-radius: var(--radius-md); + aspect-ratio: 16 / 10; + border-radius: var(--fl-radius-sm); overflow: hidden; + background: #060a12; border: 1px solid var(--border); } -.camera-frame img { +.camera__img { width: 100%; height: 100%; object-fit: cover; display: block; } -.camera-overlay { +.camera__placeholder { position: absolute; - top: 12px; - left: 12px; - z-index: 2; - pointer-events: none; + inset: 0; + display: grid; + place-items: center; + color: rgba(255, 255, 255, 0.2); } -.live-badge { +.camera__placeholder svg { width: 48px; height: 48px; } + +.camera__badge { + position: absolute; + top: 14px; + left: 14px; + z-index: 2; display: inline-flex; align-items: center; gap: 8px; - padding: 5px 10px; - border-radius: var(--radius-full); - background: var(--overlay); + padding: 6px 12px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(8px); - color: var(--text); - font-size: 10px; + color: #fff; + font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } .live-dot { - background: var(--danger); - box-shadow: 0 0 0 3px var(--danger-soft); - animation: pulse 1.6s infinite; + background: #ff4d4d; + animation: pulse 1.4s ease infinite; } -.activity-list { +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Timeline */ +.timeline { list-style: none; margin: 0; padding: 0; display: grid; - gap: 8px; + gap: 0; } -.activity-item { - display: flex; - align-items: center; - justify-content: space-between; +.timeline-item { + display: grid; + grid-template-columns: 48px 1fr auto; gap: 16px; - padding: 14px 16px; - border-radius: var(--radius-md); - background: var(--surface-strong); - border: 1px solid var(--border); - transition: transform var(--transition), border-color var(--transition); -} - -.activity-item:hover { - transform: translateX(4px); - border-color: var(--border-strong); + align-items: start; + padding: 18px 0; + border-bottom: 1px solid var(--border); + animation: rise-in 0.5s var(--ease) both; } -.activity-main { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; -} +.timeline-item:last-child { border-bottom: none; } -.activity-icon { - width: 36px; - height: 36px; +.timeline-marker { + position: relative; display: grid; place-items: center; - border-radius: var(--radius-sm); - flex-shrink: 0; - background: var(--neutral-soft); - color: var(--text-secondary); + width: 48px; + height: 48px; + border-radius: 14px; + background: var(--surface-solid); + border: 1px solid var(--border); + color: var(--text-muted); } -.activity-icon svg { width: 18px; height: 18px; } -.activity-icon.lock { background: var(--success-soft); color: var(--success); } -.activity-icon.unlock { background: var(--warning-soft); color: var(--warning); } -.activity-icon.dooropen { background: var(--danger-soft); color: var(--danger); } -.activity-icon.doorclosed { background: var(--primary-soft); color: var(--primary); } +.timeline-marker svg { width: 20px; height: 20px; } +.timeline-marker.lock { color: var(--success); background: rgba(29, 236, 134, 0.1); } +.timeline-marker.unlock { color: var(--warning); background: rgba(199, 119, 0, 0.1); } +.timeline-marker.dooropen { color: var(--danger); background: rgba(217, 45, 32, 0.08); } +.timeline-marker.doorclosed { color: var(--fl-blue); background: rgba(28, 99, 236, 0.08); } -.activity-copy strong { +.timeline-body strong { display: block; - margin-bottom: 2px; - font-size: 14px; + font-size: 15px; font-weight: 600; + margin-bottom: 2px; } -.activity-copy span { font-size: 13px; } -.activity-time { - flex-shrink: 0; - font-size: 12px; +.timeline-body span { font-size: 14px; color: var(--text-muted); } +.timeline-time { font-size: 13px; color: var(--text-muted); white-space: nowrap; padding-top: 4px; } + +.timeline-empty { + padding: 32px; + text-align: center; color: var(--text-muted); - white-space: nowrap; + font-size: 15px; } -.auth-panel { margin-bottom: 16px; } -.hidden { display: none !important; } +/* Form */ +.form { display: grid; gap: 16px; max-width: 400px; } +.field { display: grid; gap: 8px; } +.field span { font-size: 13px; font-weight: 600; color: var(--text-muted); } -@keyframes cloud-drift { - from { transform: translateX(-12vw); } - to { transform: translateX(12vw); } -} -@keyframes orb-float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(8px); } -} -@keyframes twinkle { - from { opacity: calc(var(--star-opacity) * 0.7); } - to { opacity: var(--star-opacity); } -} -@keyframes rain-fall { - from { transform: translateY(-10%); } - to { transform: translateY(10%); } +.select, input { + width: 100%; + padding: 12px 14px; + border-radius: var(--fl-radius-sm); + border: 1px solid var(--border); + background: var(--surface-solid); + color: var(--text); + font-family: inherit; + font-size: 15px; + transition: border-color 0.2s, box-shadow 0.2s; } -@keyframes snow-fall { - from { transform: translateY(-8%); } - to { transform: translateY(8%); } + +.select:focus, input:focus { + outline: none; + border-color: var(--fl-blue); + box-shadow: 0 0 0 3px rgba(28, 99, 236, 0.12); } -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.45; } + +.select { width: auto; min-width: 160px; } + +.hidden { display: none !important; } + +@media (max-width: 960px) { + .bento { grid-template-columns: 1fr; } + .nav { flex-direction: column; align-items: stretch; } + .nav__meta, .nav__actions { justify-content: space-between; } + .timeline-item { grid-template-columns: 40px 1fr; } + .timeline-time { grid-column: 2; padding-top: 0; } } -@media (max-width: 900px) { - .grid { grid-template-columns: 1fr; } - .header { flex-direction: column; align-items: stretch; } - .header-actions { flex-wrap: wrap; } - .status-row { flex-direction: column; } - .activity-item { flex-direction: column; align-items: flex-start; } - .activity-time { white-space: normal; } - .value { font-size: 24px; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } } diff --git a/src/yale_lock/templates/index.html b/src/yale_lock/templates/index.html index 2b70152..43b9373 100644 --- a/src/yale_lock/templates/index.html +++ b/src/yale_lock/templates/index.html @@ -3,254 +3,172 @@ - Home Dashboard + Home Security · Dashboard -