From 57e8eee5245eeb0fa6142c0c83f5d1ff82454bad Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 30 May 2026 19:42:00 -0400 Subject: [PATCH 001/156] sqlite layer with versioned migrations --- Software/web-server/db/__init__.py | 0 Software/web-server/db/database.py | 49 +++++++++++++ .../db/migrations/001_shot_history.sql | 30 ++++++++ Software/web-server/tests/test_database.py | 70 +++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 Software/web-server/db/__init__.py create mode 100644 Software/web-server/db/database.py create mode 100644 Software/web-server/db/migrations/001_shot_history.sql create mode 100644 Software/web-server/tests/test_database.py diff --git a/Software/web-server/db/__init__.py b/Software/web-server/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Software/web-server/db/database.py b/Software/web-server/db/database.py new file mode 100644 index 00000000..5acbe389 --- /dev/null +++ b/Software/web-server/db/database.py @@ -0,0 +1,49 @@ +import sqlite3 +import threading +from pathlib import Path +from typing import Any, List, Optional + +MIGRATIONS_DIR = Path(__file__).parent / "migrations" + + +class Database: + def __init__(self, db_path: Path): + db_path = Path(db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(db_path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA foreign_keys=ON") + self._conn.execute("PRAGMA busy_timeout=5000") + self._lock = threading.Lock() + self._migrate() + + def _migrate(self) -> None: + current = self._conn.execute("PRAGMA user_version").fetchone()[0] + for script in sorted(MIGRATIONS_DIR.glob("*.sql"), key=lambda p: int(p.name.split("_")[0])): + version = int(script.name.split("_")[0]) + if version <= current: + continue + # executescript auto-commits before the version pragma runs, so every + # statement must be IF NOT EXISTS-safe to survive a mid-migration crash + self._conn.executescript(script.read_text()) + self._conn.execute(f"PRAGMA user_version = {version}") + self._conn.commit() + + def query(self, sql: str, params: tuple = ()) -> List[sqlite3.Row]: + with self._lock: + return self._conn.execute(sql, params).fetchall() + + def execute(self, sql: str, params: tuple = ()) -> Optional[int]: + with self._lock: + cur = self._conn.execute(sql, params) + self._conn.commit() + return cur.lastrowid + + def executemany(self, sql: str, rows: List[tuple]) -> None: + with self._lock: + self._conn.executemany(sql, rows) + self._conn.commit() + + def close(self) -> None: + self._conn.close() diff --git a/Software/web-server/db/migrations/001_shot_history.sql b/Software/web-server/db/migrations/001_shot_history.sql new file mode 100644 index 00000000..e85907ad --- /dev/null +++ b/Software/web-server/db/migrations/001_shot_history.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + ended_at TEXT, + label TEXT +); + +-- shot ids come from the C++ process (epoch ms at detection), not autoincrement +CREATE TABLE IF NOT EXISTS shots ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + result_type TEXT NOT NULL, + speed REAL NOT NULL DEFAULT 0, + carry REAL NOT NULL DEFAULT 0, + launch_angle REAL NOT NULL DEFAULT 0, + side_angle REAL NOT NULL DEFAULT 0, + back_spin INTEGER NOT NULL DEFAULT 0, + side_spin INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_shots_session ON shots(session_id); + +CREATE TABLE IF NOT EXISTS shot_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shot_id INTEGER NOT NULL REFERENCES shots(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + file_path TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_shot_images_shot ON shot_images(shot_id); diff --git a/Software/web-server/tests/test_database.py b/Software/web-server/tests/test_database.py new file mode 100644 index 00000000..e951d04d --- /dev/null +++ b/Software/web-server/tests/test_database.py @@ -0,0 +1,70 @@ +import sqlite3 +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from db.database import Database + + +@pytest.mark.unit +class TestDatabase: + @pytest.fixture + def db(self, tmp_path): + instance = Database(tmp_path / "test.db") + yield instance + instance.close() + + def test_migrations_apply_on_fresh_db(self, db): + tables = { + row["name"] + for row in db.query("SELECT name FROM sqlite_master WHERE type='table'") + } + assert {"sessions", "shots", "shot_images"} <= tables + + def test_user_version_matches_latest_migration(self, db): + version = db.query("PRAGMA user_version")[0][0] + assert version == 1 + + def test_reopening_is_idempotent(self, tmp_path): + Database(tmp_path / "test.db").close() + again = Database(tmp_path / "test.db") + assert again.query("PRAGMA user_version")[0][0] == 1 + again.close() + + def test_wal_mode_enabled(self, db): + assert db.query("PRAGMA journal_mode")[0][0] == "wal" + + def test_execute_returns_lastrowid(self, db): + rowid = db.execute( + "INSERT INTO sessions (started_at) VALUES (?)", ("2026-06-01T10:00:00",) + ) + assert rowid == 1 + + def test_crash_recovery(self, tmp_path): + # First open — runs migrations, sets user_version=1 + db1 = Database(tmp_path / "test.db") + db1.close() + + # Simulate a mid-migration crash by resetting user_version to 0 + conn = sqlite3.connect(tmp_path / "test.db") + conn.execute("PRAGMA user_version = 0") + conn.commit() + conn.close() + + # Re-opening should re-run migrations without raising + db2 = Database(tmp_path / "test.db") + version = db2.query("PRAGMA user_version")[0][0] + db2.close() + + assert version == 1 + + def test_fk_enforcement(self, db): + # Inserting a shot with a session_id that doesn't exist must fail + with pytest.raises(sqlite3.IntegrityError): + db.execute( + "INSERT INTO shots (id, session_id, created_at, result_type) VALUES (?, ?, ?, ?)", + (1699999999000, 999, "2026-06-01T10:00:00", "Normal"), + ) From ee8a2b8b0373ce5fdbb38c2bb1ffb112d0e4531d Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 31 May 2026 11:25:23 -0400 Subject: [PATCH 002/156] session and shot repositories --- Software/web-server/db/repositories.py | 111 ++++++++++++++ .../web-server/tests/test_repositories.py | 140 ++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 Software/web-server/db/repositories.py create mode 100644 Software/web-server/tests/test_repositories.py diff --git a/Software/web-server/db/repositories.py b/Software/web-server/db/repositories.py new file mode 100644 index 00000000..cda5d504 --- /dev/null +++ b/Software/web-server/db/repositories.py @@ -0,0 +1,111 @@ +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple + +from db.database import Database +from models import ShotData + + +class SessionRepository: + def __init__(self, db: Database): + self.db = db + + def ensure_open(self, now_iso: str, timeout_minutes: int) -> int: + rows = self.db.query( + "SELECT id, started_at FROM sessions WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1" + ) + if rows: + session_id = rows[0]["id"] + last = self.db.query( + "SELECT MAX(created_at) AS t FROM shots WHERE session_id = ?", (session_id,) + )[0]["t"] or rows[0]["started_at"] + cutoff = datetime.fromisoformat(now_iso) - timedelta(minutes=timeout_minutes) + if datetime.fromisoformat(last) >= cutoff: + return session_id + self.db.execute("UPDATE sessions SET ended_at = ? WHERE id = ?", (now_iso, session_id)) + return self.db.execute("INSERT INTO sessions (started_at) VALUES (?)", (now_iso,)) + + def close_open(self, now_iso: str) -> None: + self.db.execute("UPDATE sessions SET ended_at = ? WHERE ended_at IS NULL", (now_iso,)) + + def get(self, session_id: int) -> Optional[Dict[str, Any]]: + rows = self.db.query("SELECT * FROM sessions WHERE id = ?", (session_id,)) + return dict(rows[0]) if rows else None + + def list(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]: + rows = self.db.query( + """SELECT s.*, COUNT(sh.id) AS shot_count + FROM sessions s LEFT JOIN shots sh ON sh.session_id = s.id + GROUP BY s.id ORDER BY s.id DESC LIMIT ? OFFSET ?""", + (limit, offset), + ) + return [dict(r) for r in rows] + + def oldest_prunable(self) -> Optional[Dict[str, Any]]: + # oldest ended session that still has image rows — keeps the prune loop advancing + rows = self.db.query( + """SELECT * FROM sessions WHERE ended_at IS NOT NULL + AND EXISTS (SELECT 1 FROM shot_images si JOIN shots s ON s.id = si.shot_id + WHERE s.session_id = sessions.id) + ORDER BY id ASC LIMIT 1""" + ) + return dict(rows[0]) if rows else None + + def delete(self, session_id: int) -> None: + self.db.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + + +class ShotRepository: + def __init__(self, db: Database): + self.db = db + + def add(self, shot_id: int, session_id: int, shot: ShotData, + images: List[Tuple[str, str]]) -> None: + self.db.execute( + """INSERT OR IGNORE INTO shots + (id, session_id, created_at, result_type, speed, carry, + launch_angle, side_angle, back_spin, side_spin, message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (shot_id, session_id, shot.timestamp, shot.result_type, shot.speed, + shot.carry, shot.launch_angle, shot.side_angle, shot.back_spin, + shot.side_spin, shot.message), + ) + # first writer wins: a re-post with different images keeps the originals, + # but a shot that arrived imageless can pick them up later + existing = self.db.query("SELECT COUNT(*) FROM shot_images WHERE shot_id = ?", (shot_id,)) + if existing[0][0] == 0 and images: + self.db.executemany( + "INSERT INTO shot_images (shot_id, kind, file_path) VALUES (?, ?, ?)", + [(shot_id, kind, path) for kind, path in images], + ) + + def _attach_images(self, shot_row: Dict[str, Any]) -> Dict[str, Any]: + imgs = self.db.query( + "SELECT kind, file_path FROM shot_images WHERE shot_id = ? ORDER BY id", + (shot_row["id"],), + ) + shot_row["images"] = [dict(i) for i in imgs] + return shot_row + + def get(self, shot_id: int) -> Optional[Dict[str, Any]]: + rows = self.db.query("SELECT * FROM shots WHERE id = ?", (shot_id,)) + return self._attach_images(dict(rows[0])) if rows else None + + def list_for_session(self, session_id: int) -> List[Dict[str, Any]]: + rows = self.db.query( + "SELECT * FROM shots WHERE session_id = ? ORDER BY id DESC", (session_id,) + ) + return [self._attach_images(dict(r)) for r in rows] + + def image_paths_for_session(self, session_id: int) -> List[str]: + rows = self.db.query( + """SELECT si.file_path FROM shot_images si + JOIN shots s ON s.id = si.shot_id WHERE s.session_id = ?""", + (session_id,), + ) + return [r["file_path"] for r in rows] + + def delete_images_for_session(self, session_id: int) -> None: + self.db.execute( + "DELETE FROM shot_images WHERE shot_id IN (SELECT id FROM shots WHERE session_id = ?)", + (session_id,), + ) diff --git a/Software/web-server/tests/test_repositories.py b/Software/web-server/tests/test_repositories.py new file mode 100644 index 00000000..fdc36153 --- /dev/null +++ b/Software/web-server/tests/test_repositories.py @@ -0,0 +1,140 @@ +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from db.database import Database +from db.repositories import SessionRepository, ShotRepository +from models import ShotData + + +@pytest.fixture +def db(tmp_path): + d = Database(tmp_path / "test.db") + yield d + d.close() + + +@pytest.fixture +def sessions(db): + return SessionRepository(db) + + +@pytest.fixture +def shots(db): + return ShotRepository(db) + + +def hit(ts="2026-06-01T10:00:00"): + return ShotData(speed=145.2, carry=265.0, launch_angle=12.4, side_angle=-2.1, + back_spin=2850, side_spin=-310, result_type="Hit", + message="Ball Hit", timestamp=ts) + + +@pytest.mark.unit +class TestSessionRepository: + def test_ensure_session_creates_then_reuses(self, sessions): + a = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + b = sessions.ensure_open("2026-06-01T10:10:00", timeout_minutes=30) + assert a == b + + def test_ensure_session_rolls_over_after_timeout(self, sessions, shots, db): + a = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1000, a, hit("2026-06-01T10:05:00"), []) + b = sessions.ensure_open("2026-06-01T11:00:00", timeout_minutes=30) + assert b != a + assert sessions.get(a)["ended_at"] is not None + + def test_close_open_sets_ended_at(self, sessions): + a = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + sessions.close_open("2026-06-01T10:30:00") + assert sessions.get(a)["ended_at"] == "2026-06-01T10:30:00" + + def test_session_list_includes_shot_count(self, sessions, shots): + s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1, s, hit(), []) + shots.add(2, s, hit(), []) + listed = sessions.list() + assert listed[0]["shot_count"] == 2 + + def test_delete_session_cascades(self, db, sessions, shots): + s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1, s, hit(), [("spin1", "x.png")]) + sessions.delete(s) + assert db.query("SELECT COUNT(*) FROM shots")[0][0] == 0 + assert db.query("SELECT COUNT(*) FROM shot_images")[0][0] == 0 + + def test_oldest_prunable_returns_ended_session_with_images(self, sessions, shots): + s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1, s, hit(), [("spin1", "x.png")]) + sessions.close_open("2026-06-01T10:30:00") + result = sessions.oldest_prunable() + assert result is not None + assert result["id"] == s + + def test_oldest_prunable_ignores_open_sessions(self, sessions, shots): + # open session with images — must not be returned + s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1, s, hit(), [("spin1", "x.png")]) + result = sessions.oldest_prunable() + assert result is None + + def test_oldest_prunable_ignores_ended_sessions_without_images(self, sessions, shots): + s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1, s, hit(), []) # no images + sessions.close_open("2026-06-01T10:30:00") + result = sessions.oldest_prunable() + assert result is None + + def test_oldest_prunable_returns_oldest_when_multiple(self, sessions, shots): + s1 = sessions.ensure_open("2026-06-01T09:00:00", timeout_minutes=30) + shots.add(1, s1, hit(), [("spin1", "a.png")]) + sessions.close_open("2026-06-01T09:30:00") + + s2 = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(2, s2, hit(), [("spin1", "b.png")]) + sessions.close_open("2026-06-01T10:30:00") + + result = sessions.oldest_prunable() + assert result["id"] == s1 + + +@pytest.mark.unit +class TestShotRepository: + def test_shot_insert_is_idempotent(self, sessions, shots): + s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1717236000123, s, hit(), [("spin1", "shots/1717236000123/spin1.png")]) + shots.add(1717236000123, s, hit(), [("spin1", "shots/1717236000123/spin1.png")]) + rows = shots.list_for_session(s) + assert len(rows) == 1 + assert len(rows[0]["images"]) == 1 + + def test_get_returns_shot_with_images(self, sessions, shots): + s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1717236000123, s, hit(), [("spin1", "a.png")]) + shot = shots.get(1717236000123) + assert shot["result_type"] == "Hit" + assert shot["images"] == [{"kind": "spin1", "file_path": "a.png"}] + assert shots.get(999) is None + + def test_image_paths_for_session(self, sessions, shots): + s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(1, s, hit(), [("spin1", "a.png"), ("spin2", "b.png")]) + paths = shots.image_paths_for_session(s) + assert set(paths) == {"a.png", "b.png"} + + def test_delete_images_for_session_removes_only_that_session(self, sessions, shots, db): + s1 = sessions.ensure_open("2026-06-01T09:00:00", timeout_minutes=30) + shots.add(1, s1, hit(), [("spin1", "s1.png")]) + sessions.close_open("2026-06-01T09:30:00") + + s2 = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shots.add(2, s2, hit(), [("spin1", "s2.png")]) + + shots.delete_images_for_session(s1) + + remaining = db.query("SELECT file_path FROM shot_images") + assert len(remaining) == 1 + assert remaining[0]["file_path"] == "s2.png" From d36be2efccb7815b981420ac59a7bac220d042f9 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 2 Jun 2026 20:37:51 -0400 Subject: [PATCH 003/156] persist shots as they come in --- Software/web-server/configurations.json | 16 +++++++ Software/web-server/constants.py | 1 + Software/web-server/models.py | 6 ++- Software/web-server/server.py | 18 ++++++++ Software/web-server/tests/conftest.py | 7 ++- .../web-server/tests/test_shot_persistence.py | 45 +++++++++++++++++++ 6 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 Software/web-server/tests/test_shot_persistence.py diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 39d0de38..b30ffc4d 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -150,6 +150,22 @@ "internal": true } }, + "storage": { + "sessionTimeoutMinutes": { + "displayName": "Session Timeout Minutes", + "description": "Minutes of inactivity before a new session starts", + "type": "number", + "default": 30, + "internal": true + }, + "imageStorageCapMB": { + "displayName": "Image Storage Cap MB", + "description": "Maximum disk space for stored shot images", + "type": "number", + "default": 5000, + "internal": true + } + }, "environmentDefaults": { "ldLibraryPath": { "displayName": "LD Library Path", diff --git a/Software/web-server/constants.py b/Software/web-server/constants.py index d0dd33df..e7f3eb0d 100644 --- a/Software/web-server/constants.py +++ b/Software/web-server/constants.py @@ -8,3 +8,4 @@ HOME_DIR = Path(os.environ.get("HOME", str(Path.home()))) PITRAC_DIR = HOME_DIR / ".pitrac" IMAGES_DIR = Path(os.environ.get("PITRAC_IMAGES_DIR", str(HOME_DIR / "LM_Shares" / "Images"))) +DB_PATH = Path(os.environ.get("PITRAC_DB_PATH", str(PITRAC_DIR / "pitrac.db"))) diff --git a/Software/web-server/models.py b/Software/web-server/models.py index 45778685..1b9c1895 100644 --- a/Software/web-server/models.py +++ b/Software/web-server/models.py @@ -1,6 +1,6 @@ -from dataclasses import dataclass, asdict +from dataclasses import dataclass, asdict, field from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional class ResultType(Enum): @@ -27,6 +27,8 @@ class ShotData: result_type: str = "Waiting for ball..." message: str = "" timestamp: Optional[str] = None + shot_id: Optional[int] = None + images: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: return asdict(self) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 83b4aac8..798b291d 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -17,9 +17,12 @@ from camera_detector import CameraDetector from config_manager import ConfigurationManager from constants import ( + DB_PATH, IMAGES_DIR, MPS_TO_MPH, ) +from db.database import Database +from db.repositories import SessionRepository, ShotRepository from managers import ConnectionManager, ShotDataStore from models import ShotData from parsers import ShotDataParser @@ -186,6 +189,11 @@ def __init__(self): self.strobe_calibration_manager = StrobeCalibrationManager(self.config_manager) self.update_manager = UpdateManager() self.update_manager.set_broadcast_callback(self.connection_manager.broadcast) + self.db = Database(DB_PATH) + self.session_repo = SessionRepository(self.db) + self.shot_repo = ShotRepository(self.db) + metadata = self.config_manager.load_configurations_metadata() + self.session_timeout_minutes = metadata.get("storage", {}).get("sessionTimeoutMinutes", {}).get("default", 30) self.shutdown_flag = False self.background_tasks: set[asyncio.Task] = set() self._active_cameras: Dict[int, str] = {} # camera_index -> endpoint name @@ -292,6 +300,12 @@ async def receive_shot_result(request: Request) -> Dict[str, str]: message=message, timestamp=datetime.now().isoformat(), ) + shot_data.shot_id = body.get("shot_id") + shot_data.images = list(body.get("images", [])) + if result_type_str == "Hit": + shot_id = shot_data.shot_id or int(datetime.now().timestamp() * 1000) + images = [(Path(p).stem, p) for p in shot_data.images] + await asyncio.to_thread(self._persist_shot, shot_id, shot_data, images) self.shot_store.update(shot_data) await self.connection_manager.broadcast(shot_data.to_dict()) @@ -1344,6 +1358,10 @@ async def _run_tool_async(self, tool_id: str) -> None: self.testing_manager.completed_results = {} self.testing_manager.completed_results[tool_id] = {"status": "error", "message": str(e)} + def _persist_shot(self, shot_id, shot_data, images): + session_id = self.session_repo.ensure_open(shot_data.timestamp, self.session_timeout_minutes) + self.shot_repo.add(shot_id, session_id, shot_data, images) + async def shutdown_event(self) -> None: logger.info("Shutting down PiTrac Web Server...") diff --git a/Software/web-server/tests/conftest.py b/Software/web-server/tests/conftest.py index 859a09ca..5484f004 100644 --- a/Software/web-server/tests/conftest.py +++ b/Software/web-server/tests/conftest.py @@ -12,6 +12,9 @@ os.environ["TESTING"] = "true" +import tempfile +os.environ.setdefault("PITRAC_DB_PATH", str(Path(tempfile.mkdtemp()) / "import-time.db")) + from models import ShotData from managers import ConnectionManager, ShotDataStore from parsers import ShotDataParser @@ -24,8 +27,10 @@ @pytest.fixture -def server_instance(): +def server_instance(monkeypatch, tmp_path): """Create PiTracServer instance with mocked dependencies""" + import server as server_module + monkeypatch.setattr(server_module, "DB_PATH", tmp_path / "test.db") server = PiTracServer() server.shutdown_flag = False return server diff --git a/Software/web-server/tests/test_shot_persistence.py b/Software/web-server/tests/test_shot_persistence.py new file mode 100644 index 00000000..62578651 --- /dev/null +++ b/Software/web-server/tests/test_shot_persistence.py @@ -0,0 +1,45 @@ +import pytest + + +def make_hit_payload(shot_id=1717236000123): + return { + "result_type": 7, "speed_mps": 65.0, "launch_angle": 12.4, + "side_angle": -2.1, "back_spin": 2850, "side_spin": -310, + "carry": 180, "message": "Ball Hit - Results returned.", + "shot_id": shot_id, + "images": ["shots/1717236000123/spin1.png", "shots/1717236000123/spin2.png"], + } + + +@pytest.mark.unit +class TestShotPersistence: + + def test_hit_is_persisted(self, client, server_instance): + resp = client.post("/api/internal/shot-result", json=make_hit_payload()) + assert resp.status_code == 200 + shot = server_instance.shot_repo.get(1717236000123) + assert shot is not None + assert shot["result_type"] == "Hit" + assert len(shot["images"]) == 2 + + def test_status_message_not_persisted(self, client, server_instance): + client.post("/api/internal/shot-result", json={"result_type": 2, "message": "waiting"}) + assert server_instance.session_repo.list() == [] + + def test_fake_hit_not_persisted(self, client, server_instance): + client.post("/api/internal/shot-result", + json={"result_type": 7, "message": "Club type was set"}) + assert server_instance.session_repo.list() == [] + + def test_duplicate_post_is_idempotent(self, client, server_instance): + client.post("/api/internal/shot-result", json=make_hit_payload()) + client.post("/api/internal/shot-result", json=make_hit_payload()) + sessions = server_instance.session_repo.list() + assert sessions[0]["shot_count"] == 1 + + def test_hit_without_shot_id_still_persists(self, client, server_instance): + payload = make_hit_payload() + del payload["shot_id"] + del payload["images"] + client.post("/api/internal/shot-result", json=payload) + assert server_instance.session_repo.list()[0]["shot_count"] == 1 From f4fef86c62ad3000abda213288ea7254b54c180a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 3 Jun 2026 15:29:34 -0400 Subject: [PATCH 004/156] close the session when pitrac stops --- Software/web-server/server.py | 2 ++ Software/web-server/tests/test_shot_persistence.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 798b291d..5903ebbb 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -515,6 +515,8 @@ async def stop_pitrac() -> Dict[str, Any]: """Stop the PiTrac launch monitor process""" result = await self.pitrac_manager.stop() logger.info(f"PiTrac stop request: {result}") + if result.get("status") == "stopped": + self.session_repo.close_open(datetime.now().isoformat()) return result @self.app.post("/api/pitrac/restart") diff --git a/Software/web-server/tests/test_shot_persistence.py b/Software/web-server/tests/test_shot_persistence.py index 62578651..113ef8e3 100644 --- a/Software/web-server/tests/test_shot_persistence.py +++ b/Software/web-server/tests/test_shot_persistence.py @@ -1,4 +1,5 @@ import pytest +from unittest.mock import AsyncMock def make_hit_payload(shot_id=1717236000123): @@ -43,3 +44,14 @@ def test_hit_without_shot_id_still_persists(self, client, server_instance): del payload["images"] client.post("/api/internal/shot-result", json=payload) assert server_instance.session_repo.list()[0]["shot_count"] == 1 + + def test_stop_closes_open_session(self, client, server_instance): + client.post("/api/internal/shot-result", json=make_hit_payload()) + assert server_instance.session_repo.list()[0]["ended_at"] is None + + server_instance.pitrac_manager.stop = AsyncMock( + return_value={"status": "stopped"} + ) + client.post("/api/pitrac/stop") + + assert server_instance.session_repo.list()[0]["ended_at"] is not None From 396da616165fcced8f2625a1c0d95f0ee9e1ab57 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 4 Jun 2026 11:58:30 -0400 Subject: [PATCH 005/156] give each shot its own image folder --- .../LMSourceCode/ImageProcessing/gs_fsm.cpp | 16 +++- .../ImageProcessing/gs_ui_system.cpp | 74 +++++++++++++++++-- .../ImageProcessing/gs_ui_system.h | 20 ++++- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index e583c244..f77c245e 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -128,6 +128,10 @@ namespace golf_sim { // Let the monitor interface know what's happening GsUISystem::SendIPCStatusMessage(GsIPCResultType::kInitializing); + // A restart abandons any in-progress shot (cam2 timeout, trigger failure) without + // flushing a result POST, so drop its stale shot id/images before the next attempt. + GsUISystem::ResetCurrentShot(); + // If we're already armed, just start waiting for a ball to appear. if (GsSimInterface::GetAllSystemsArmed()) { GolfSimEventElement beginWaitingForBallPlacedEvent{ new GolfSimEvent::BeginWaitingForBallPlaced{ } }; @@ -482,17 +486,21 @@ namespace golf_sim { auto velocity_time_period_string = GS_FORMATLIB_FORMAT("{: <6.2f}", velocity_time_period); s = " Time between chosen images for velocity calculation: " + velocity_time_period_string + " ms."; - GsUISystem::SendIPCHitMessage(result_ball, s); - -#ifdef __unix__ + // Save the exposure-candidates image and announce it BEFORE SendIPCHitMessage: + // that call flushes the accumulated image paths into the POST and resets the + // shot id, so any image saved after it would land under a fresh shot. +#ifdef __unix__ if (exposures_image.empty()) { GS_LOG_MSG(warning, "Exposures_image from ProcessReceivedCamera2 was empty."); } GsUISystem::SaveWebserverImage(GsUISystem::kWebServerResultBallExposureCandidates, exposures_image, exposure_balls); - GsHttpClient::PostImageReady(GsUISystem::kWebServerResultBallExposureCandidates + ".png"); + GsHttpClient::PostImageReady( + GsUISystem::CurrentShotRelativePath(GsUISystem::kWebServerResultBallExposureCandidates + ".png")); #endif + GsUISystem::SendIPCHitMessage(result_ball, s); + } // Setup to go through the whole sequence again diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index c09bd8a0..ade54ff7 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -6,6 +6,11 @@ #ifdef __unix__ // Ignore in Windows environment +#include +#include +#include +#include + #include "logging_tools.h" #include "gs_result_types.h" @@ -27,6 +32,25 @@ namespace golf_sim { std::string GsUISystem::kWebServerErrorExposuresImage; std::string GsUISystem::kWebServerBallSearchAreaImage; + long GsUISystem::current_shot_id_ = 0; + std::vector GsUISystem::current_shot_image_paths_; + std::mutex GsUISystem::shot_images_mutex_; + + std::string GsUISystem::CurrentShotRelativePath(const std::string& file_name) { + std::lock_guard lock(shot_images_mutex_); + if (current_shot_id_ == 0) { + current_shot_id_ = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + } + return "shots/" + std::to_string(current_shot_id_) + "/" + file_name; + } + + void GsUISystem::ResetCurrentShot() { + std::lock_guard lock(shot_images_mutex_); + current_shot_id_ = 0; + current_shot_image_paths_.clear(); + } + static std::string EscapeJson(const std::string& s) { std::string out; out.reserve(s.size() + 16); @@ -47,7 +71,8 @@ namespace golf_sim { float speed_mps = 0, float launch_deg = 0, float side_deg = 0, int back_spin = 0, int side_spin = 0, int carry_m = 0, - const std::vector& images = {}) { + const std::vector& images = {}, + long shot_id = 0) { std::string json = "{"; json += "\"result_type\":" + std::to_string(result_type); json += ",\"speed_mps\":" + std::to_string(speed_mps); @@ -62,7 +87,11 @@ namespace golf_sim { if (i > 0) json += ","; json += "\"" + EscapeJson(images[i]) + "\""; } - json += "]}"; + json += "]"; + if (shot_id != 0) { + json += ",\"shot_id\":" + std::to_string(shot_id); + } + json += "}"; return json; } @@ -76,9 +105,22 @@ namespace golf_sim { msg = error_message; } + // Flush whatever images this failed attempt accumulated so they attach to this + // shot's result and don't bleed into the next shot. + std::vector images; + long shot_id; + { + std::lock_guard lock(shot_images_mutex_); + images = current_shot_image_paths_; + shot_id = current_shot_id_; + current_shot_image_paths_.clear(); + current_shot_id_ = 0; + } + GS_LOG_TRACE_MSG(trace, "Sending error result: " + msg); GsHttpClient::PostResult(BuildResultJson( - static_cast(GsIPCResultType::kError), msg)); + static_cast(GsIPCResultType::kError), msg, + 0, 0, 0, 0, 0, 0, images, shot_id)); } @@ -138,6 +180,14 @@ namespace golf_sim { int carry = 100 + rand() % 150; std::vector images; + long shot_id; + { + std::lock_guard lock(shot_images_mutex_); + images = current_shot_image_paths_; + shot_id = current_shot_id_; + current_shot_image_paths_.clear(); + current_shot_id_ = 0; + } std::string msg = "Ball Hit - Results returned." + secondary_message; @@ -152,7 +202,7 @@ namespace golf_sim { GsHttpClient::PostResult(BuildResultJson( static_cast(GsIPCResultType::kHit), msg, - speed, launch, side, back_spin, side_spin, carry, images)); + speed, launch, side, back_spin, side_spin, carry, images, shot_id)); } @@ -181,11 +231,25 @@ namespace golf_sim { file_name += ".png"; } - std::string fname = kWebServerShareDirectory + file_name; + // Route every shot image into its own per-shot directory so shot history survives. + // NOTE: CurrentShotRelativePath takes shot_images_mutex_, so call it before taking + // the lock below to record the path (otherwise we'd deadlock). + std::string relative = CurrentShotRelativePath(file_name); + std::string fname = kWebServerShareDirectory + relative; + std::filesystem::create_directories(std::filesystem::path(fname).parent_path()); try { if (cv::imwrite(fname, img)) { GS_LOG_TRACE_MSG(trace, "Logged image to file: " + fname); + + // Record the relative path once per shot. Some images (e.g. the ball + // search-area image) are re-saved every wait-loop cycle; overwriting the + // file on disk is fine, but we must not flood the POST/DB with dupes. + std::lock_guard lock(shot_images_mutex_); + if (std::find(current_shot_image_paths_.begin(), current_shot_image_paths_.end(), relative) + == current_shot_image_paths_.end()) { + current_shot_image_paths_.push_back(relative); + } } else { GS_LOG_MSG(warning, "GsUISystem::SaveWebserverImage - could not save to file name: " + fname); diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h index c8d706b4..5766a2d8 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h @@ -10,6 +10,10 @@ #ifdef __unix__ // Ignore in Windows environment +#include +#include +#include + #include "logging_tools.h" #include "golf_ball.h" #include "gs_result_types.h" @@ -30,7 +34,21 @@ namespace golf_sim { static std::string kWebServerResultBallRotatedByBestAngles; static std::string kWebServerErrorExposuresImage; static std::string kWebServerBallSearchAreaImage; - + + // Per-shot image bookkeeping. shot_id is an epoch-ms id minted once per shot + // (lazily, when the first image of the shot is saved) and reset when the shot's + // result is flushed to the web server. Image paths are relative to the share dir. + static long current_shot_id_; + static std::vector current_shot_image_paths_; + static std::mutex shot_images_mutex_; + + // Returns "shots//", minting the shot id if not already set. + static std::string CurrentShotRelativePath(const std::string& file_name); + + // Clears the current shot id and accumulated image paths so the next shot starts + // fresh. Used when a shot is abandoned (timeout/trigger failure) without a result POST. + static void ResetCurrentShot(); + static void SendIPCErrorStatusMessage(const std::string& error_message); From bf4e0d3380bcca9269581b5935c8d16d247b3739 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 5 Jun 2026 09:27:59 -0400 Subject: [PATCH 006/156] handle per shot image paths in the ui --- Software/web-server/server.py | 6 +++-- Software/web-server/static/js/dashboard.js | 2 +- .../web-server/tests/test_api_endpoints.py | 25 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 5903ebbb..9b32160e 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -246,9 +246,11 @@ async def get_current_shot() -> Dict[str, Any]: async def get_shot_history(limit: int = 10) -> list: return [shot.to_dict() for shot in self.shot_store.get_history(limit)] - @self.app.get("/api/images/{filename}", response_model=None) + @self.app.get("/api/images/{filename:path}", response_model=None) async def get_image(filename: str): - image_path = IMAGES_DIR / filename + image_path = (IMAGES_DIR / filename).resolve() + if not image_path.is_relative_to(IMAGES_DIR.resolve()): + return {"error": "Image not found"} if image_path.exists() and image_path.is_file(): return FileResponse(image_path) return {"error": "Image not found"} diff --git a/Software/web-server/static/js/dashboard.js b/Software/web-server/static/js/dashboard.js index 4352f225..24b1dbd3 100644 --- a/Software/web-server/static/js/dashboard.js +++ b/Software/web-server/static/js/dashboard.js @@ -212,7 +212,7 @@ document.addEventListener('DOMContentLoaded', () => { updateBallStatus(null, null, false); - // Check if a shot image already exists on disk (e.g. page refresh after a shot) + // Best-effort pre-per-shot-dir fallback: try to restore the last shot image on refresh const img = new Image(); img.onload = () => handleImageReady('ball_exposure_candidates.png'); img.src = '/images/ball_exposure_candidates.png?t=' + Date.now(); diff --git a/Software/web-server/tests/test_api_endpoints.py b/Software/web-server/tests/test_api_endpoints.py index a61757a8..64780b0d 100644 --- a/Software/web-server/tests/test_api_endpoints.py +++ b/Software/web-server/tests/test_api_endpoints.py @@ -134,6 +134,31 @@ def test_image_not_found(self, client): assert response.status_code == 200 assert response.json() == {"error": "Image not found"} + def test_image_endpoint_nested_path(self, client, tmp_path): + """Slashed paths like shots/123/x.png are routed and served correctly""" + with patch("server.IMAGES_DIR", tmp_path): + nested = tmp_path / "shots" / "1717236000123" + nested.mkdir(parents=True) + (nested / "ball_exposure_candidates.png").write_bytes(b"fake image data") + + response = client.get("/api/images/shots/1717236000123/ball_exposure_candidates.png") + assert response.status_code == 200 + + def test_image_endpoint_nested_not_found(self, client, tmp_path): + """A slashed path that doesn't exist returns the not-found JSON (not a 404 routing error)""" + with patch("server.IMAGES_DIR", tmp_path): + response = client.get("/api/images/shots/999/missing.png") + assert response.status_code == 200 + assert response.json() == {"error": "Image not found"} + + def test_image_traversal_blocked(self, client, tmp_path): + """URL-encoded traversal (%2e%2e) is decoded by the router and blocked by is_relative_to""" + with patch("server.IMAGES_DIR", tmp_path): + # %2e%2e decodes to '..' — Starlette passes it as '../etc/passwd' to the handler + response = client.get("/api/images/%2e%2e/etc/passwd") + assert response.status_code == 200 + assert response.json() == {"error": "Image not found"} + def test_cors_headers(self, client): """Test CORS headers are present if needed""" response = client.get("/api/shot") From 143c6b625c7311956524baba247266ec77305b8b Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 5 Jun 2026 18:55:10 -0400 Subject: [PATCH 007/156] prune old shot images when storage fills up --- Software/web-server/db/repositories.py | 9 + Software/web-server/retention.py | 77 ++++++++ Software/web-server/server.py | 23 ++- Software/web-server/tests/test_retention.py | 204 ++++++++++++++++++++ 4 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 Software/web-server/retention.py create mode 100644 Software/web-server/tests/test_retention.py diff --git a/Software/web-server/db/repositories.py b/Software/web-server/db/repositories.py index cda5d504..c1fca897 100644 --- a/Software/web-server/db/repositories.py +++ b/Software/web-server/db/repositories.py @@ -109,3 +109,12 @@ def delete_images_for_session(self, session_id: int) -> None: "DELETE FROM shot_images WHERE shot_id IN (SELECT id FROM shots WHERE session_id = ?)", (session_id,), ) + + def shot_ids_with_images(self) -> set: + """Set of shot ids that have at least one shot_images row.""" + rows = self.db.query("SELECT DISTINCT shot_id FROM shot_images") + return {r["shot_id"] for r in rows} + + def shot_ids_for_session(self, session_id: int) -> list: + rows = self.db.query("SELECT id FROM shots WHERE session_id = ?", (session_id,)) + return [r["id"] for r in rows] diff --git a/Software/web-server/retention.py b/Software/web-server/retention.py new file mode 100644 index 00000000..8b32c592 --- /dev/null +++ b/Software/web-server/retention.py @@ -0,0 +1,77 @@ +import logging +import shutil +from datetime import datetime, timedelta +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Orphan dirs younger than this are left alone — they may still be receiving writes. +_ORPHAN_GRACE = timedelta(hours=24) + + +def _dir_bytes(shots_dir: Path) -> int: + """Sum file sizes under shots_dir; returns 0 if the dir doesn't exist.""" + if not shots_dir.is_dir(): + return 0 + return sum(f.stat().st_size for f in shots_dir.rglob("*") if f.is_file()) + + +class ImageRetention: + def __init__(self, session_repo, shot_repo, images_dir: Path, cap_mb: int): + self._sessions = session_repo + self._shots = shot_repo + self._images_dir = images_dir + self._cap_bytes = cap_mb * 1024 * 1024 + + def prune(self, now: datetime | None = None) -> None: + shots_dir = self._images_dir / "shots" + total = _dir_bytes(shots_dir) + if total <= self._cap_bytes: + return + + # Step 2: prune ended sessions oldest-first until under cap or no prunable remain. + # oldest_prunable() advances each iteration because we remove that session's image + # rows, so it can never return the same session twice → no infinite loop. + while total > self._cap_bytes: + session = self._sessions.oldest_prunable() + if session is None: + break # only open sessions or image-less sessions remain; stop + sid = session["id"] + for rel in self._shots.image_paths_for_session(sid): + (self._images_dir / rel).unlink(missing_ok=True) + # Remove emptied per-shot dirs + self._rmdir_if_empty_for_session(sid) + self._shots.delete_images_for_session(sid) + total = _dir_bytes(shots_dir) + logger.info(f"pruned session {sid} images; remaining {total // (1024*1024)}MB") + + # Step 3: orphan sweep — shots/ dirs with no shot_images rows. + # C++ may write images for error-status shots that never get persisted. + if total > self._cap_bytes and shots_dir.is_dir(): + _now = now or datetime.now() + cutoff = (_now - _ORPHAN_GRACE).timestamp() + known = self._shots.shot_ids_with_images() + for d in shots_dir.iterdir(): + if not d.is_dir(): + continue + try: + dir_id = int(d.name) + except ValueError: + continue + if dir_id in known: + continue + if d.stat().st_mtime <= cutoff: + shutil.rmtree(d, ignore_errors=True) + logger.info(f"pruned orphan dir {d.name}") + + def _rmdir_if_empty_for_session(self, session_id: int) -> None: + """Remove shots/ dirs that are now empty for a given session's shots.""" + shot_ids = self._shots.shot_ids_for_session(session_id) + shots_dir = self._images_dir / "shots" + for sid in shot_ids: + d = shots_dir / str(sid) + if d.is_dir(): + try: + d.rmdir() # only removes if empty + except OSError: + pass # non-empty or already gone; leave it diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 9b32160e..a2251448 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -14,6 +14,7 @@ from fastapi.templating import Jinja2Templates from calibration_manager import CalibrationManager +from retention import ImageRetention from camera_detector import CameraDetector from config_manager import ConfigurationManager from constants import ( @@ -194,6 +195,8 @@ def __init__(self): self.shot_repo = ShotRepository(self.db) metadata = self.config_manager.load_configurations_metadata() self.session_timeout_minutes = metadata.get("storage", {}).get("sessionTimeoutMinutes", {}).get("default", 30) + self.image_cap_mb = metadata.get("storage", {}).get("imageStorageCapMB", {}).get("default", 5000) + self.retention = ImageRetention(self.session_repo, self.shot_repo, IMAGES_DIR, self.image_cap_mb) self.shutdown_flag = False self.background_tasks: set[asyncio.Task] = set() self._active_cameras: Dict[int, str] = {} # camera_index -> endpoint name @@ -1331,6 +1334,14 @@ async def _stream_file_logs(self, websocket: WebSocket, log_file: Path) -> None: except Exception as e: logger.error(f"Error streaming file logs: {e}") + async def _maintenance_loop(self) -> None: + while not self.shutdown_flag: + await asyncio.sleep(300) + try: + await asyncio.to_thread(self.retention.prune) + except Exception: + logger.exception("retention prune failed; will retry next cycle") + async def startup_event(self) -> None: logger.info("Starting PiTrac Web Server...") loop = asyncio.get_event_loop() @@ -1344,6 +1355,10 @@ async def startup_event(self) -> None: "V3 DAC not initialized — strobe calibration required before PiTrac can run" ) + task = asyncio.create_task(self._maintenance_loop()) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + logger.info("PiTrac Web Server ready — receiving results via HTTP POST") async def _run_tool_async(self, tool_id: str) -> None: @@ -1371,12 +1386,14 @@ async def shutdown_event(self) -> None: self.shutdown_flag = True - for task in self.background_tasks: + loop = asyncio.get_event_loop() + current_tasks = {t for t in self.background_tasks if t.get_loop() is loop} + for task in current_tasks: if not task.done(): task.cancel() - if self.background_tasks: - await asyncio.gather(*self.background_tasks, return_exceptions=True) + if current_tasks: + await asyncio.gather(*current_tasks, return_exceptions=True) for ws in self.connection_manager.connections: try: diff --git a/Software/web-server/tests/test_retention.py b/Software/web-server/tests/test_retention.py new file mode 100644 index 00000000..ec4ef151 --- /dev/null +++ b/Software/web-server/tests/test_retention.py @@ -0,0 +1,204 @@ +import os +import sys +import time +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from db.database import Database +from db.repositories import SessionRepository, ShotRepository +from models import ShotData +from retention import ImageRetention + + +# ── helpers ──────────────────────────────────────────────────────────────── + +def _hit(ts="2026-06-01T10:00:00"): + return ShotData( + speed=145.0, carry=265.0, launch_angle=12.0, side_angle=0.0, + back_spin=2800, side_spin=0, result_type="Hit", + message="Ball Hit", timestamp=ts, + ) + + +def _write_file(path: Path, size_bytes: int = 1024) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * size_bytes) + + +def _make_session(sessions, shots, images_dir: Path, shot_id: int, + ts: str = "2026-06-01T10:00:00", + size_bytes: int = 512 * 1024) -> tuple[int, Path]: + """Create a session with one shot + one image on disk. Returns (session_id, file_path).""" + sid = sessions.ensure_open(ts, timeout_minutes=30) + rel = f"shots/{shot_id}/spin1.png" + disk_path = images_dir / rel + _write_file(disk_path, size_bytes) + shots.add(shot_id, sid, _hit(ts), [("spin1", rel)]) + return sid, disk_path + + +# ── fixtures ──────────────────────────────────────────────────────────────── + +@pytest.fixture +def db(tmp_path): + d = Database(tmp_path / "t.db") + yield d + d.close() + + +@pytest.fixture +def session_repo(db): + return SessionRepository(db) + + +@pytest.fixture +def shot_repo(db): + return ShotRepository(db) + + +@pytest.fixture +def images_dir(tmp_path): + d = tmp_path / "images" + d.mkdir() + return d + + +def make_retention(session_repo, shot_repo, images_dir, cap_mb): + return ImageRetention(session_repo, shot_repo, images_dir, cap_mb) + + +# ── tests ──────────────────────────────────────────────────────────────────── + +@pytest.mark.unit +class TestImageRetention: + + def test_under_cap_is_noop(self, session_repo, shot_repo, images_dir): + sid, fpath = _make_session(session_repo, shot_repo, images_dir, + shot_id=1, size_bytes=100) + session_repo.close_open("2026-06-01T10:30:00") + + r = make_retention(session_repo, shot_repo, images_dir, cap_mb=500) + r.prune() + + assert fpath.exists() + assert len(shot_repo.image_paths_for_session(sid)) == 1 + + def test_prunes_oldest_ended_session_first(self, session_repo, shot_repo, images_dir): + # session 1 (older) — 600 KB + sid1, f1 = _make_session(session_repo, shot_repo, images_dir, + shot_id=1, ts="2026-06-01T09:00:00", + size_bytes=600 * 1024) + session_repo.close_open("2026-06-01T09:30:00") + + # session 2 (newer) — 100 KB (small, will survive once sid1 pruned) + sid2, f2 = _make_session(session_repo, shot_repo, images_dir, + shot_id=2, ts="2026-06-01T10:00:00", + size_bytes=100 * 1024) + session_repo.close_open("2026-06-01T10:30:00") + + # total ~700 KB; cap 200 KB → prune sid1 (600 KB); remainder 100 KB ≤ 200 KB → done + r = ImageRetention(session_repo, shot_repo, images_dir, cap_mb=0) + r._cap_bytes = 200 * 1024 + r.prune() + + assert not f1.exists(), "oldest session image should be pruned" + assert f2.exists(), "newer session image should survive" + assert shot_repo.image_paths_for_session(sid1) == [] + assert len(shot_repo.image_paths_for_session(sid2)) == 1 + # shot ROW for sid1 still present — rows are never deleted + assert shot_repo.get(1) is not None + + def test_open_session_never_pruned(self, session_repo, shot_repo, images_dir): + sid, fpath = _make_session(session_repo, shot_repo, images_dir, + shot_id=10, size_bytes=600 * 1024) + # session is still OPEN + + r = make_retention(session_repo, shot_repo, images_dir, cap_mb=0) + r.prune() + + assert fpath.exists(), "open session image must survive" + assert len(shot_repo.image_paths_for_session(sid)) == 1 + + def test_terminates_when_only_unprunable_remains(self, session_repo, shot_repo, images_dir): + # open session only; over cap → prune should return cleanly + _make_session(session_repo, shot_repo, images_dir, + shot_id=20, size_bytes=600 * 1024) + + r = make_retention(session_repo, shot_repo, images_dir, cap_mb=0) + # must not hang + r.prune() + # image still there + assert (images_dir / "shots" / "20" / "spin1.png").exists() + + def test_orphan_dir_pruned_when_old_and_over_cap(self, session_repo, shot_repo, images_dir): + # create an orphan dir — no shot_images row + orphan_dir = images_dir / "shots" / "999" + orphan_dir.mkdir(parents=True) + orphan_file = orphan_dir / "frame.png" + _write_file(orphan_file, 600 * 1024) + + # backdate mtime to 2 days ago + old = time.time() - 48 * 3600 + os.utime(orphan_dir, (old, old)) + + r = make_retention(session_repo, shot_repo, images_dir, cap_mb=0) + r.prune() + + assert not orphan_dir.exists(), "old orphan dir should be deleted" + + def test_orphan_dir_kept_when_recent(self, session_repo, shot_repo, images_dir): + orphan_dir = images_dir / "shots" / "888" + orphan_dir.mkdir(parents=True) + orphan_file = orphan_dir / "frame.png" + _write_file(orphan_file, 600 * 1024) + # mtime is current (fresh write) — should be kept + + r = make_retention(session_repo, shot_repo, images_dir, cap_mb=0) + r.prune() + + assert orphan_dir.exists(), "recent orphan dir must be kept" + + def test_shot_rows_survive_pruning(self, session_repo, shot_repo, images_dir): + sid, _ = _make_session(session_repo, shot_repo, images_dir, + shot_id=5, size_bytes=600 * 1024) + session_repo.close_open("2026-06-01T10:30:00") + + r = make_retention(session_repo, shot_repo, images_dir, cap_mb=0) + r.prune() + + shot = shot_repo.get(5) + assert shot is not None, "shot row must survive pruning" + assert shot["result_type"] == "Hit" + # images column is empty (rows deleted) but shot exists + assert shot["images"] == [] + + def test_empty_shots_dir_counts_as_zero(self, session_repo, shot_repo, images_dir): + # no shots dir at all → noop, no crash + r = make_retention(session_repo, shot_repo, images_dir, cap_mb=1) + r.prune() # should not raise + + +@pytest.mark.unit +class TestShotIdsWithImages: + + def test_returns_ids_that_have_image_rows(self, session_repo, shot_repo): + sid = session_repo.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shot_repo.add(101, sid, _hit(), [("spin1", "shots/101/s.png")]) + shot_repo.add(102, sid, _hit(), []) # no images + result = shot_repo.shot_ids_with_images() + assert result == {101} + + def test_empty_when_no_images(self, session_repo, shot_repo): + sid = session_repo.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shot_repo.add(200, sid, _hit(), []) + assert shot_repo.shot_ids_with_images() == set() + + def test_reflects_deletions(self, session_repo, shot_repo): + sid = session_repo.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + shot_repo.add(300, sid, _hit(), [("spin1", "shots/300/s.png")]) + session_repo.close_open("2026-06-01T10:30:00") + shot_repo.delete_images_for_session(sid) + assert shot_repo.shot_ids_with_images() == set() From e6ee64e6567d17024225241442dd4825399556fa Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 6 Jun 2026 19:36:31 -0400 Subject: [PATCH 008/156] shot history api --- Software/web-server/server.py | 55 +++++- Software/web-server/tests/test_history_api.py | 179 ++++++++++++++++++ 2 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 Software/web-server/tests/test_history_api.py diff --git a/Software/web-server/server.py b/Software/web-server/server.py index a2251448..32c9b3d8 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -8,13 +8,13 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Union -from fastapi import FastAPI, File, Request, UploadFile, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from calibration_manager import CalibrationManager -from retention import ImageRetention +from retention import ImageRetention, _dir_bytes from camera_detector import CameraDetector from config_manager import ConfigurationManager from constants import ( @@ -1115,6 +1115,43 @@ async def detect_cameras() -> Dict[str, Any]: }, } + # Shot history API + @self.app.get("/api/sessions") + async def list_sessions(limit: int = 50, offset: int = 0) -> list: + return await asyncio.to_thread(self.session_repo.list, limit, offset) + + @self.app.get("/api/sessions/{session_id}/shots") + async def list_shots_for_session(session_id: int) -> list: + session = await asyncio.to_thread(self.session_repo.get, session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + return await asyncio.to_thread(self.shot_repo.list_for_session, session_id) + + @self.app.get("/api/shots/{shot_id}") + async def get_shot(shot_id: int) -> Dict[str, Any]: + shot = await asyncio.to_thread(self.shot_repo.get, shot_id) + if shot is None: + raise HTTPException(status_code=404, detail="Shot not found") + return shot + + @self.app.delete("/api/sessions/{session_id}") + async def delete_session(session_id: int) -> Dict[str, Any]: + session = await asyncio.to_thread(self.session_repo.get, session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + image_paths = await asyncio.to_thread( + self.shot_repo.image_paths_for_session, session_id + ) + await asyncio.to_thread(self.session_repo.delete, session_id) + await asyncio.to_thread(self._delete_session_files, image_paths) + return {"status": "deleted", "session_id": session_id} + + @self.app.get("/api/storage/usage") + async def storage_usage() -> Dict[str, Any]: + shots_dir = IMAGES_DIR / "shots" + used_bytes = await asyncio.to_thread(_dir_bytes, shots_dir) + return {"used_mb": used_bytes // (1024 * 1024), "cap_mb": self.image_cap_mb} + @self.app.get("/api/cameras/types") async def get_camera_types() -> Dict[str, Any]: """Get available camera types and their descriptions""" @@ -1381,6 +1418,20 @@ def _persist_shot(self, shot_id, shot_data, images): session_id = self.session_repo.ensure_open(shot_data.timestamp, self.session_timeout_minutes) self.shot_repo.add(shot_id, session_id, shot_data, images) + def _delete_session_files(self, image_paths: List[str]) -> None: + """Unlink image files and remove emptied per-shot dirs.""" + parent_dirs: set[Path] = set() + for rel in image_paths: + p = IMAGES_DIR / rel + p.unlink(missing_ok=True) + parent_dirs.add(p.parent) + for d in parent_dirs: + if d.is_dir(): + try: + d.rmdir() + except OSError: + pass + async def shutdown_event(self) -> None: logger.info("Shutting down PiTrac Web Server...") diff --git a/Software/web-server/tests/test_history_api.py b/Software/web-server/tests/test_history_api.py new file mode 100644 index 00000000..9b789ad9 --- /dev/null +++ b/Software/web-server/tests/test_history_api.py @@ -0,0 +1,179 @@ +import pytest +from fastapi.testclient import TestClient + + +def make_hit_payload(shot_id=1717236000123): + return { + "result_type": 7, + "speed_mps": 65.0, + "launch_angle": 12.4, + "side_angle": -2.1, + "back_spin": 2850, + "side_spin": -310, + "carry": 180, + "message": "Ball Hit - Results returned.", + "shot_id": shot_id, + "images": [ + f"shots/{shot_id}/spin1.png", + f"shots/{shot_id}/spin2.png", + ], + } + + +@pytest.fixture +def history_server(monkeypatch, tmp_path): + """Server with both DB and IMAGES_DIR redirected to tmp_path.""" + import server as server_module + monkeypatch.setattr(server_module, "DB_PATH", tmp_path / "test.db") + monkeypatch.setattr(server_module, "IMAGES_DIR", tmp_path / "images") + from server import PiTracServer + srv = PiTracServer() + return srv + + +@pytest.fixture +def history_client(history_server): + return TestClient(history_server.app) + + +@pytest.mark.unit +class TestSessionsEndpoint: + + def test_list_sessions_with_shots(self, history_client, history_server): + history_client.post("/api/internal/shot-result", json=make_hit_payload(111)) + history_client.post("/api/internal/shot-result", json=make_hit_payload(222)) + + resp = history_client.get("/api/sessions") + assert resp.status_code == 200 + sessions = resp.json() + assert len(sessions) == 1 + assert sessions[0]["shot_count"] == 2 + + def test_list_sessions_empty(self, history_client): + resp = history_client.get("/api/sessions") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_sessions_limit_offset(self, history_client, history_server): + history_client.post("/api/internal/shot-result", json=make_hit_payload(333)) + resp = history_client.get("/api/sessions?limit=5&offset=0") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + def test_list_sessions_newest_first(self, history_client, history_server): + # Force two separate sessions by closing the first one + history_client.post("/api/internal/shot-result", json=make_hit_payload(444)) + history_server.session_repo.close_open("2020-01-01T00:00:00") + history_client.post("/api/internal/shot-result", json=make_hit_payload(555)) + + sessions = history_client.get("/api/sessions").json() + assert len(sessions) == 2 + assert sessions[0]["id"] > sessions[1]["id"] + + +@pytest.mark.unit +class TestShotsForSessionEndpoint: + + def test_shots_for_session_returns_list_with_images(self, history_client, history_server): + history_client.post("/api/internal/shot-result", json=make_hit_payload(1001)) + session_id = history_server.session_repo.list()[0]["id"] + + resp = history_client.get(f"/api/sessions/{session_id}/shots") + assert resp.status_code == 200 + shots = resp.json() + assert len(shots) == 1 + assert shots[0]["result_type"] == "Hit" + assert len(shots[0]["images"]) == 2 + assert shots[0]["images"][0]["file_path"].startswith("shots/") + + def test_shots_for_session_unknown_id_returns_404(self, history_client): + resp = history_client.get("/api/sessions/99999/shots") + assert resp.status_code == 404 + + def test_shots_for_session_newest_first(self, history_client, history_server): + history_client.post("/api/internal/shot-result", json=make_hit_payload(2001)) + history_client.post("/api/internal/shot-result", json=make_hit_payload(2002)) + session_id = history_server.session_repo.list()[0]["id"] + + shots = history_client.get(f"/api/sessions/{session_id}/shots").json() + assert len(shots) == 2 + assert shots[0]["id"] > shots[1]["id"] + + +@pytest.mark.unit +class TestGetShotEndpoint: + + def test_get_shot_returns_shot(self, history_client, history_server): + history_client.post("/api/internal/shot-result", json=make_hit_payload(3001)) + + resp = history_client.get("/api/shots/3001") + assert resp.status_code == 200 + shot = resp.json() + assert shot["id"] == 3001 + assert shot["result_type"] == "Hit" + assert "images" in shot + + def test_get_shot_unknown_id_returns_404(self, history_client): + resp = history_client.get("/api/shots/99999") + assert resp.status_code == 404 + + +@pytest.mark.unit +class TestDeleteSessionEndpoint: + + def test_delete_removes_session_and_shots(self, history_client, history_server): + history_client.post("/api/internal/shot-result", json=make_hit_payload(4001)) + session_id = history_server.session_repo.list()[0]["id"] + + resp = history_client.delete(f"/api/sessions/{session_id}") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "deleted" + assert body["session_id"] == session_id + + # DB rows gone + assert history_server.session_repo.get(session_id) is None + assert history_server.shot_repo.list_for_session(session_id) == [] + + def test_delete_removes_image_files(self, history_client, history_server, tmp_path): + shot_id = 4002 + shots_dir = (tmp_path / "images" / "shots" / str(shot_id)) + shots_dir.mkdir(parents=True) + img1 = shots_dir / "spin1.png" + img2 = shots_dir / "spin2.png" + img1.write_bytes(b"fake") + img2.write_bytes(b"fake") + + history_client.post("/api/internal/shot-result", json=make_hit_payload(shot_id)) + session_id = history_server.session_repo.list()[0]["id"] + + history_client.delete(f"/api/sessions/{session_id}") + + assert not img1.exists() + assert not img2.exists() + # parent dir also removed since it's now empty + assert not shots_dir.exists() + + def test_delete_unknown_session_returns_404(self, history_client): + resp = history_client.delete("/api/sessions/99999") + assert resp.status_code == 404 + + +@pytest.mark.unit +class TestStorageUsageEndpoint: + + def test_storage_usage_returns_int_and_cap(self, history_client, history_server, tmp_path): + resp = history_client.get("/api/storage/usage") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data["used_mb"], int) + assert data["cap_mb"] == history_server.image_cap_mb + + def test_storage_usage_accounts_for_files(self, history_client, history_server, tmp_path): + shots_dir = tmp_path / "images" / "shots" / "5001" + shots_dir.mkdir(parents=True) + # write 2 MB of data + (shots_dir / "big.bin").write_bytes(b"x" * (2 * 1024 * 1024)) + + resp = history_client.get("/api/storage/usage") + assert resp.json()["used_mb"] >= 2 From ad09b1b7690a591da0d009be68e99b4e19919a9a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 6 Jun 2026 20:08:59 -0400 Subject: [PATCH 009/156] shot history page with per shot images --- Software/web-server/server.py | 5 + Software/web-server/static/js/history.js | 309 ++++++++++++++++++ Software/web-server/templates/base.html | 1 + Software/web-server/templates/history.html | 57 ++++ Software/web-server/tests/test_history_api.py | 11 + 5 files changed, 383 insertions(+) create mode 100644 Software/web-server/static/js/history.js create mode 100644 Software/web-server/templates/history.html diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 32c9b3d8..ea1507cb 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1198,6 +1198,11 @@ async def cancel_update() -> Dict[str, Any]: async def update_status() -> Dict[str, Any]: return self.update_manager.get_status() + @self.app.get("/history", response_class=HTMLResponse) + async def history_page(request: Request) -> Response: + """Serve shot history page""" + return self.templates.TemplateResponse(request, "history.html") + @self.app.get("/logs", response_class=HTMLResponse) async def logs_page(request: Request) -> Response: """Serve logs viewer page""" diff --git a/Software/web-server/static/js/history.js b/Software/web-server/static/js/history.js new file mode 100644 index 00000000..c15dd1e7 --- /dev/null +++ b/Software/web-server/static/js/history.js @@ -0,0 +1,309 @@ +// Shot history page +let selectedSessionId = null; + +async function loadStorageUsage() { + try { + const resp = await fetch('/api/storage/usage'); + const data = await resp.json(); + const el = document.getElementById('storage-usage'); + const used = data.used_mb >= 1024 + ? (data.used_mb / 1024).toFixed(1) + ' GB' + : data.used_mb + ' MB'; + const cap = data.cap_mb >= 1024 + ? (data.cap_mb / 1024).toFixed(1) + ' GB' + : data.cap_mb + ' MB'; + el.textContent = `${used} of ${cap} used`; + } catch (err) { + console.error('Failed to load storage usage:', err); + document.getElementById('storage-usage').textContent = 'Storage info unavailable'; + } +} + +async function loadSessions() { + const container = document.getElementById('sessions-list'); + try { + const resp = await fetch('/api/sessions'); + const sessions = await resp.json(); + + if (sessions.length === 0) { + container.innerHTML = '
No sessions yet.
'; + return; + } + + container.innerHTML = ''; + sessions.forEach(session => { + const card = document.createElement('div'); + card.className = 'card bg-base-300 border border-base-300 p-3 cursor-pointer hover:border-primary transition-colors'; + card.dataset.sessionId = session.id; + + const started = new Date(session.started_at).toLocaleString(); + const label = session.label ? `
${escapeHtml(session.label)}
` : ''; + + card.innerHTML = ` +
+
+ ${label} +
${started}
+
${session.shot_count} shot${session.shot_count !== 1 ? 's' : ''}
+
+ +
+ `; + + container.appendChild(card); + }); + + if (typeof lucide !== 'undefined') lucide.createIcons(); + + // re-select the previously selected session if it still exists + if (selectedSessionId !== null) { + const existing = container.querySelector(`[data-session-id="${selectedSessionId}"]`); + if (existing) { + existing.classList.add('border-primary'); + } else { + selectedSessionId = null; + clearShots(); + } + } + } catch (err) { + console.error('Failed to load sessions:', err); + container.innerHTML = '
Failed to load sessions.
'; + } +} + +function clearShots() { + document.getElementById('shots-heading').textContent = 'Shots'; + document.getElementById('shots-list').innerHTML = + '
Select a session to view shots.
'; + clearDetail(); +} + +function clearDetail() { + document.getElementById('shot-detail').innerHTML = + '
Select a shot to view details.
'; +} + +async function loadShots(sessionId) { + const container = document.getElementById('shots-list'); + container.innerHTML = '
Loading...
'; + + try { + const resp = await fetch(`/api/sessions/${sessionId}/shots`); + if (!resp.ok) { + container.innerHTML = '
Session not found.
'; + return; + } + + const shots = await resp.json(); + document.getElementById('shots-heading').textContent = `Shots (${shots.length})`; + + if (shots.length === 0) { + container.innerHTML = '
No shots in this session.
'; + return; + } + + const table = document.createElement('table'); + table.className = 'table table-xs w-full'; + table.innerHTML = ` + + + Time + Type + Speed + Carry + Launch° + Side° + + + + `; + + container.innerHTML = ''; + container.appendChild(table); + + const tbody = document.getElementById('shots-tbody'); + shots.forEach(shot => { + const tr = document.createElement('tr'); + tr.className = 'cursor-pointer hover'; + tr.dataset.shotId = shot.id; + + const t = new Date(shot.created_at).toLocaleTimeString(); + const speed = shot.speed != null ? shot.speed.toFixed(1) : '--'; + const carry = shot.carry != null ? shot.carry.toFixed(0) : '--'; + const launch = shot.launch_angle != null ? shot.launch_angle.toFixed(1) : '--'; + const side = shot.side_angle != null ? shot.side_angle.toFixed(1) : '--'; + + tr.innerHTML = ` + ${t} + ${escapeHtml(shot.result_type || '--')} + ${speed} + ${carry} + ${launch} + ${side} + `; + + tbody.appendChild(tr); + }); + + } catch (err) { + console.error('Failed to load shots:', err); + container.innerHTML = '
Failed to load shots.
'; + } +} + +async function loadShotDetail(shotId) { + const container = document.getElementById('shot-detail'); + container.innerHTML = '
Loading...
'; + + try { + const resp = await fetch(`/api/shots/${shotId}`); + if (!resp.ok) { + container.innerHTML = '
Shot not found.
'; + return; + } + + const shot = await resp.json(); + const ts = new Date(shot.created_at).toLocaleString(); + + const metrics = [ + ['Time', ts], + ['Type', shot.result_type || '--'], + ['Speed', shot.speed != null ? shot.speed.toFixed(1) + ' mph' : '--'], + ['Carry', shot.carry != null ? shot.carry.toFixed(0) + ' yd' : '--'], + ['Launch Angle', shot.launch_angle != null ? shot.launch_angle.toFixed(1) + '°' : '--'], + ['Side Angle', shot.side_angle != null ? shot.side_angle.toFixed(1) + '°' : '--'], + ['Back Spin', shot.back_spin != null ? shot.back_spin.toFixed(0) + ' rpm' : '--'], + ['Side Spin', shot.side_spin != null ? shot.side_spin.toFixed(0) + ' rpm' : '--'], + ]; + + if (shot.message) { + metrics.push(['Message', shot.message]); + } + + let html = '
'; + metrics.forEach(([label, value]) => { + html += ` +
+ ${escapeHtml(label)} + ${escapeHtml(String(value))} +
+ `; + }); + html += '
'; + + if (shot.images && shot.images.length > 0) { + html += '
Images
'; + html += '
'; + shot.images.forEach((img, idx) => { + html += `
+ ${escapeHtml(img.kind || 'image')} +
${escapeHtml(img.kind || img.file_path)}
+
`; + }); + html += '
'; + } + + container.innerHTML = html; + + // wire onerror for images via JS — no inline handlers + container.querySelectorAll('img').forEach(img => { + img.addEventListener('error', () => { + const wrapper = img.closest('.shot-img-wrapper'); + if (wrapper) { + wrapper.innerHTML = '
Image expired or not available
'; + } + }); + }); + + } catch (err) { + console.error('Failed to load shot detail:', err); + container.innerHTML = '
Failed to load shot.
'; + } +} + +async function deleteSession(sessionId) { + if (!confirm('Delete this session and all its shots? This cannot be undone.')) return; + + try { + const resp = await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' }); + if (!resp.ok) { + alert('Failed to delete session.'); + return; + } + if (selectedSessionId === sessionId) { + selectedSessionId = null; + clearShots(); + } + await loadSessions(); + await loadStorageUsage(); + } catch (err) { + console.error('Failed to delete session:', err); + alert('Failed to delete session.'); + } +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function highlightSession(sessionId) { + document.querySelectorAll('#sessions-list [data-session-id]').forEach(el => { + if (!el.dataset.sessionId) return; + if (parseInt(el.dataset.sessionId, 10) === sessionId) { + el.classList.add('border-primary'); + } else { + el.classList.remove('border-primary'); + } + }); +} + +function highlightShot(shotId) { + document.querySelectorAll('#shots-tbody tr').forEach(tr => { + if (parseInt(tr.dataset.shotId, 10) === shotId) { + tr.classList.add('active'); + } else { + tr.classList.remove('active'); + } + }); +} + +document.addEventListener('DOMContentLoaded', () => { + loadStorageUsage(); + loadSessions(); + + // session clicks via delegation + document.getElementById('sessions-list').addEventListener('click', async e => { + const deleteBtn = e.target.closest('.delete-session-btn'); + if (deleteBtn) { + e.stopPropagation(); + const sid = parseInt(deleteBtn.dataset.sessionId, 10); + await deleteSession(sid); + return; + } + + const card = e.target.closest('[data-session-id]'); + if (!card || card.classList.contains('delete-session-btn')) return; + + const sid = parseInt(card.dataset.sessionId, 10); + selectedSessionId = sid; + clearDetail(); + highlightSession(sid); + await loadShots(sid); + }); + + // shot row clicks via delegation + document.getElementById('shots-list').addEventListener('click', async e => { + const tr = e.target.closest('tr[data-shot-id]'); + if (!tr) return; + + const sid = parseInt(tr.dataset.shotId, 10); + highlightShot(sid); + await loadShotDetail(sid); + }); +}); diff --git a/Software/web-server/templates/base.html b/Software/web-server/templates/base.html index 8de1c57c..ebf6f784 100644 --- a/Software/web-server/templates/base.html +++ b/Software/web-server/templates/base.html @@ -62,6 +62,7 @@ {{ nav_item("/", "home", "Dashboard") }} {{ nav_item("/calibration", "crosshair", "Calibration") }} {{ nav_item("/config", "settings", "Configuration") }} + {{ nav_item("/history", "list", "Shot History") }} {{ nav_item("/logs", "file-text", "Logs") }} {{ nav_item("/testing", "flask-conical", "Testing Tools") }} {{ nav_item("/update", "refresh-cw", "System Update") }} diff --git a/Software/web-server/templates/history.html b/Software/web-server/templates/history.html new file mode 100644 index 00000000..e4b69bed --- /dev/null +++ b/Software/web-server/templates/history.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} + +{% block title %}PiTrac Shot History{% endblock %} + +{% block content %} +
+ + +
+
+
+ +

Shot History

+
+
Loading storage...
+
+
+ +
+ + +
+
+

Sessions

+
+
+
Loading...
+
+
+ + +
+
+

Shots

+
+
+
Select a session to view shots.
+
+
+ + +
+
+

Shot Detail

+
+
+
Select a shot to view details.
+
+
+ +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/Software/web-server/tests/test_history_api.py b/Software/web-server/tests/test_history_api.py index 9b789ad9..0b9e8b3f 100644 --- a/Software/web-server/tests/test_history_api.py +++ b/Software/web-server/tests/test_history_api.py @@ -177,3 +177,14 @@ def test_storage_usage_accounts_for_files(self, history_client, history_server, resp = history_client.get("/api/storage/usage") assert resp.json()["used_mb"] >= 2 + + +@pytest.mark.unit +class TestHistoryPageSmoke: + + def test_history_page_returns_200(self, history_client): + resp = history_client.get("/history") + assert resp.status_code == 200 + assert "text/html" in resp.headers["content-type"] + assert "Shot History" in resp.text + assert "history.js" in resp.text From 8b95094982e63db1ed74d50210e78eea05af65ab Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 6 Jun 2026 23:55:57 -0400 Subject: [PATCH 010/156] per run log files instead of one giant pitrac.log --- Software/web-server/configurations.json | 14 ++ Software/web-server/log_files.py | 83 +++++++ Software/web-server/pitrac_manager.py | 16 +- Software/web-server/tests/test_log_files.py | 240 ++++++++++++++++++++ 4 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 Software/web-server/log_files.py create mode 100644 Software/web-server/tests/test_log_files.py diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index b30ffc4d..d9918940 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -164,6 +164,20 @@ "type": "number", "default": 5000, "internal": true + }, + "logDirectoryCapMB": { + "displayName": "Log Directory Cap MB", + "description": "Maximum total disk space for all per-run log files", + "type": "number", + "default": 30, + "internal": true + }, + "runLogCapMB": { + "displayName": "Run Log Cap MB", + "description": "Maximum size for a single per-run log file before truncation", + "type": "number", + "default": 50, + "internal": true } }, "environmentDefaults": { diff --git a/Software/web-server/log_files.py b/Software/web-server/log_files.py new file mode 100644 index 00000000..0609124b --- /dev/null +++ b/Software/web-server/log_files.py @@ -0,0 +1,83 @@ +import os +import re +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Tuple + +RUN_LOG_RE = re.compile(r"^pitrac-\d{8}-\d{6}\.log$") +BLOCK = 8192 + + +def run_log_path(log_dir: Path, started_at: datetime) -> Path: + return log_dir / f"pitrac-{started_at:%Y%m%d-%H%M%S}.log" + + +def list_run_logs(log_dir: Path) -> List[Path]: + if not log_dir.is_dir(): + return [] + return sorted(p for p in log_dir.glob("pitrac-*.log") if RUN_LOG_RE.match(p.name)) + + +def latest_run_log(log_dir: Path) -> Optional[Path]: + logs = list_run_logs(log_dir) + return logs[-1] if logs else None + + +def _read_back(path: Path, end: int, n: int) -> Tuple[bytes, int]: + """Read backwards from byte `end` until >= n newlines collected. Returns (data, start).""" + chunks, newlines = [], 0 + with open(path, "rb") as f: + while end > 0 and newlines <= n: + start = max(0, end - BLOCK) + f.seek(start) + chunk = f.read(end - start) + chunks.append(chunk) + newlines += chunk.count(b"\n") + end = start + return b"".join(reversed(chunks)), end + + +def _last_lines(data: bytes, data_start: int, data_end: int, n: int) -> Tuple[List[str], int]: + if not data: + return [], data_start + trailing_newline = data.endswith(b"\n") + parts = data.split(b"\n") + if trailing_newline: + parts.pop() + selected = parts[-n:] + selected_bytes = sum(len(p) + 1 for p in selected) + if not trailing_newline and selected: + selected_bytes -= 1 + offset = data_end - selected_bytes + return [p.decode("utf-8", errors="replace") for p in selected], max(data_start, offset) + + +def tail_lines(path: Path, n: int) -> Tuple[List[str], int]: + size = path.stat().st_size + data, start = _read_back(path, size, n) + return _last_lines(data, start, size, n) + + +def read_chunk_before(path: Path, offset: int, n: int) -> Tuple[List[str], int]: + """n lines ending at byte offset; returns (lines, new_offset). offset 0 → ([], 0).""" + end = min(offset, path.stat().st_size) + data, start = _read_back(path, end, n) + return _last_lines(data, start, end, n) + + +def prune_run_logs(log_dir: Path, cap_bytes: int) -> None: + # legacy single-file log from old installs is always the first prune candidate + logs = [p for p in [log_dir / "pitrac.log"] if p.exists()] + list_run_logs(log_dir) + total = sum(p.stat().st_size for p in logs) + for p in logs[:-1]: # never the live/latest file + if total <= cap_bytes: + break + total -= p.stat().st_size + p.unlink(missing_ok=True) + + +def truncate_if_over(path: Path, cap_bytes: int) -> bool: + if path.exists() and path.stat().st_size > cap_bytes: + os.truncate(path, 0) + return True + return False diff --git a/Software/web-server/pitrac_manager.py b/Software/web-server/pitrac_manager.py index 55506c99..197992b9 100644 --- a/Software/web-server/pitrac_manager.py +++ b/Software/web-server/pitrac_manager.py @@ -7,9 +7,11 @@ import os import signal import subprocess +from datetime import datetime from pathlib import Path from typing import Optional, Dict, Any from config_manager import ConfigurationManager +from log_files import run_log_path, latest_run_log, prune_run_logs, tail_lines logger = logging.getLogger(__name__) @@ -32,9 +34,14 @@ def expand_path(path_str: str) -> Path: log_dir = expand_path(sys_paths.get("logDirectory", {}).get("default", "~/.pitrac/logs")) pid_dir = expand_path(sys_paths.get("pidDirectory", {}).get("default", "~/.pitrac/run")) - self.log_file = log_dir / proc_mgmt.get("camera1LogFile", {}).get("default", "pitrac.log") + self.log_dir = log_dir + self.log_file = latest_run_log(log_dir) or (log_dir / "pitrac.log") self.pid_file = pid_dir / proc_mgmt.get("camera1PidFile", {}).get("default", "pitrac.pid") + storage = metadata.get("storage", {}) + self.log_dir_cap_bytes = storage.get("logDirectoryCapMB", {}).get("default", 30) * 1024 * 1024 + self.run_log_cap_bytes = storage.get("runLogCapMB", {}).get("default", 50) * 1024 * 1024 + self.process_check_command = proc_mgmt.get("processCheckCommand", {}).get("default", "pitrac_lm") self.startup_delay = proc_mgmt.get("startupDelayCamera1", {}).get("default", 3) self.shutdown_grace_period = proc_mgmt.get("shutdownGracePeriod", {}).get("default", 5) @@ -147,6 +154,9 @@ async def start(self) -> Dict[str, Any]: cmd = self._build_command(config_file_path=generated_config_path) + self.log_file = run_log_path(self.log_dir, datetime.now()) + prune_run_logs(self.log_dir, self.log_dir_cap_bytes) + with open(self.log_file, "a") as log: process = subprocess.Popen( cmd, @@ -271,9 +281,7 @@ def get_status(self) -> Dict[str, Any]: if self.log_file.exists(): try: - with open(self.log_file, "r") as f: - lines = f.readlines() - status["recent_logs"] = lines[-self.recent_log_lines:] if len(lines) > self.recent_log_lines else lines + status["recent_logs"] = tail_lines(self.log_file, self.recent_log_lines)[0] except Exception as e: status["log_error"] = str(e) diff --git a/Software/web-server/tests/test_log_files.py b/Software/web-server/tests/test_log_files.py new file mode 100644 index 00000000..a6476301 --- /dev/null +++ b/Software/web-server/tests/test_log_files.py @@ -0,0 +1,240 @@ +""" +Tests for the log_files helper module. +""" + +import pytest +from datetime import datetime +from pathlib import Path + +from log_files import ( + run_log_path, + list_run_logs, + latest_run_log, + tail_lines, + read_chunk_before, + prune_run_logs, + truncate_if_over, + BLOCK, +) + + +class TestRunLogPath: + @pytest.mark.unit + def test_name_format(self, tmp_path): + result = run_log_path(tmp_path, datetime(2026, 6, 12, 18, 30, 45)) + assert result.name == "pitrac-20260612-183045.log" + assert result.parent == tmp_path + + +class TestListRunLogs: + @pytest.mark.unit + def test_returns_only_strict_pattern_files(self, tmp_path): + # create matching run logs + (tmp_path / "pitrac-20260610-100000.log").write_text("a") + (tmp_path / "pitrac-20260611-120000.log").write_text("b") + (tmp_path / "pitrac-20260612-183045.log").write_text("c") + # stray files that must NOT appear + (tmp_path / "pitrac.log").write_text("legacy") + (tmp_path / "other.log").write_text("noise") + (tmp_path / "pitrac-bad.log").write_text("no timestamp") + + results = list_run_logs(tmp_path) + names = [p.name for p in results] + + assert names == [ + "pitrac-20260610-100000.log", + "pitrac-20260611-120000.log", + "pitrac-20260612-183045.log", + ] + + @pytest.mark.unit + def test_empty_dir(self, tmp_path): + assert list_run_logs(tmp_path) == [] + + @pytest.mark.unit + def test_nonexistent_dir(self, tmp_path): + assert list_run_logs(tmp_path / "nope") == [] + + +class TestLatestRunLog: + @pytest.mark.unit + def test_returns_newest_run_file_not_legacy(self, tmp_path): + # pitrac.log sorts AFTER "pitrac-2026..." lexically ("pitrac." > "pitrac-") + # but latest_run_log must never return it + (tmp_path / "pitrac-20260610-100000.log").write_text("old run") + (tmp_path / "pitrac-20260612-183045.log").write_text("new run") + (tmp_path / "pitrac.log").write_text("legacy — must not be returned") + + result = latest_run_log(tmp_path) + assert result is not None + assert result.name == "pitrac-20260612-183045.log" + + @pytest.mark.unit + def test_returns_none_when_no_run_logs(self, tmp_path): + # only legacy present + (tmp_path / "pitrac.log").write_text("legacy") + assert latest_run_log(tmp_path) is None + + @pytest.mark.unit + def test_returns_none_for_empty_dir(self, tmp_path): + assert latest_run_log(tmp_path) is None + + +class TestTailLines: + @pytest.mark.unit + def test_small_file_fewer_lines_than_n(self, tmp_path): + path = tmp_path / "small.log" + path.write_text("alpha\nbeta\ngamma\n") + lines, offset = tail_lines(path, 100) + assert lines == ["alpha", "beta", "gamma"] + assert offset == 0 + + @pytest.mark.unit + def test_trailing_newline_offset(self, tmp_path): + path = tmp_path / "trail.log" + content = "line1\nline2\nline3\nline4\nline5\n" + path.write_text(content) + lines, offset = tail_lines(path, 3) + assert lines == ["line3", "line4", "line5"] + # offset should be at the start of "line3\n" + assert offset == len("line1\nline2\n") + + @pytest.mark.unit + def test_no_trailing_newline_offset(self, tmp_path): + path = tmp_path / "notail.log" + content = "line1\nline2\nline3\nline4\nline5" # no final newline + path.write_bytes(content.encode()) + lines, offset = tail_lines(path, 3) + assert lines == ["line3", "line4", "line5"] + assert offset == len("line1\nline2\n") + + @pytest.mark.unit + def test_large_file_block_boundary(self, tmp_path): + """5MB file with numbered lines — tail must return the exact last 100.""" + path = tmp_path / "big.log" + line_template = "line {}\n" + total = 0 + lines_written = 0 + with open(path, "wb") as f: + i = 0 + while total < 5 * 1024 * 1024: + data = f"line {i}\n".encode() + f.write(data) + total += len(data) + lines_written = i + 1 + i += 1 + + n = 100 + result_lines, _ = tail_lines(path, n) + assert len(result_lines) == n + # the last written line index is lines_written - 1 + expected = [f"line {lines_written - n + j}" for j in range(n)] + assert result_lines == expected + + @pytest.mark.unit + def test_exact_n_lines(self, tmp_path): + path = tmp_path / "exact.log" + path.write_text("\n".join(f"L{i}" for i in range(10)) + "\n") + lines, offset = tail_lines(path, 10) + assert lines == [f"L{i}" for i in range(10)] + assert offset == 0 + + +class TestReadChunkBefore: + @pytest.mark.unit + def test_reads_lines_before_tail_offset(self, tmp_path): + path = tmp_path / "chunk.log" + content = "\n".join(f"line{i}" for i in range(20)) + "\n" + path.write_text(content) + + tail, tail_offset = tail_lines(path, 5) + assert len(tail) == 5 + + prev_lines, prev_offset = read_chunk_before(path, tail_offset, 5) + assert len(prev_lines) == 5 + assert prev_lines[-1] == "line14" # line just before the tail window + assert prev_offset < tail_offset + + @pytest.mark.unit + def test_offset_zero_returns_empty(self, tmp_path): + path = tmp_path / "zero.log" + path.write_text("some content\n") + lines, offset = read_chunk_before(path, 0, 10) + assert lines == [] + assert offset == 0 + + +class TestPruneRunLogs: + @pytest.mark.unit + def test_deletes_oldest_run_files_first(self, tmp_path): + old = tmp_path / "pitrac-20260601-000000.log" + mid = tmp_path / "pitrac-20260602-000000.log" + new = tmp_path / "pitrac-20260603-000000.log" + for p in (old, mid, new): + p.write_bytes(b"x" * 1000) + + # cap = 2000 → need to drop at least one; old should go first + prune_run_logs(tmp_path, 2000) + + assert not old.exists() + assert new.exists() # latest never deleted + + @pytest.mark.unit + def test_legacy_pitrac_log_deleted_before_run_files(self, tmp_path): + legacy = tmp_path / "pitrac.log" + run1 = tmp_path / "pitrac-20260601-000000.log" + run2 = tmp_path / "pitrac-20260602-000000.log" + legacy.write_bytes(b"x" * 2000) + run1.write_bytes(b"x" * 500) + run2.write_bytes(b"x" * 500) # latest run file + + # cap = 2000; total = 3000 → need to shed 1000; legacy is first candidate + prune_run_logs(tmp_path, 2000) + + assert not legacy.exists() + assert run1.exists() + assert run2.exists() # latest always kept + + @pytest.mark.unit + def test_under_cap_nothing_deleted(self, tmp_path): + run1 = tmp_path / "pitrac-20260601-000000.log" + run2 = tmp_path / "pitrac-20260602-000000.log" + run1.write_bytes(b"x" * 100) + run2.write_bytes(b"x" * 100) + + prune_run_logs(tmp_path, 1_000_000) + + assert run1.exists() + assert run2.exists() + + @pytest.mark.unit + def test_single_latest_file_never_deleted_even_if_over_cap(self, tmp_path): + sole = tmp_path / "pitrac-20260601-000000.log" + sole.write_bytes(b"x" * 10000) + + prune_run_logs(tmp_path, 1) # cap of 1 byte, but sole is the latest + + assert sole.exists() + + +class TestTruncateIfOver: + @pytest.mark.unit + def test_truncates_oversized_file(self, tmp_path): + path = tmp_path / "big.log" + path.write_bytes(b"x" * 1000) + result = truncate_if_over(path, 500) + assert result is True + assert path.stat().st_size == 0 + + @pytest.mark.unit + def test_leaves_undersized_file_alone(self, tmp_path): + path = tmp_path / "small.log" + path.write_bytes(b"x" * 100) + result = truncate_if_over(path, 500) + assert result is False + assert path.stat().st_size == 100 + + @pytest.mark.unit + def test_nonexistent_file_returns_false(self, tmp_path): + result = truncate_if_over(tmp_path / "nope.log", 500) + assert result is False From dc831680e4f421fe09c92870f6c169b0ddbb2ba7 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 7 Jun 2026 20:17:24 -0400 Subject: [PATCH 011/156] stop reading whole log files into memory, add scrollback --- Software/web-server/log_files.py | 23 +++ Software/web-server/server.py | 136 +++++++++++---- Software/web-server/tests/test_logs_api.py | 189 +++++++++++++++++++++ 3 files changed, 319 insertions(+), 29 deletions(-) diff --git a/Software/web-server/log_files.py b/Software/web-server/log_files.py index 0609124b..155ac572 100644 --- a/Software/web-server/log_files.py +++ b/Software/web-server/log_files.py @@ -1,3 +1,4 @@ +import asyncio import os import re from datetime import datetime @@ -81,3 +82,25 @@ def truncate_if_over(path: Path, cap_bytes: int) -> bool: os.truncate(path, 0) return True return False + + +async def follow(path: Path, poll_interval: float = 0.5): + """Yield new lines appended to `path`, polling for growth. Resets on truncate.""" + pos = path.stat().st_size if path.exists() else 0 + pending = b"" + while True: + if path.exists(): + size = path.stat().st_size + if size < pos: # truncate backstop fired (marathon-run cap) + pos = 0 + pending = b"" + if size > pos: + with open(path, "rb") as f: + f.seek(pos) + pending += f.read(size - pos) + pos = size + # hold back any trailing partial line until its newline arrives + *complete, pending = pending.split(b"\n") + for line in complete: + yield line.decode("utf-8", errors="replace") + await asyncio.sleep(poll_interval) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index ea1507cb..baf2f4ee 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -23,6 +23,15 @@ MPS_TO_MPH, ) from db.database import Database +from log_files import ( + follow, + latest_run_log, + list_run_logs, + prune_run_logs, + read_chunk_before, + tail_lines, + truncate_if_over, +) from db.repositories import SessionRepository, ShotRepository from managers import ConnectionManager, ShotDataStore from models import ShotData @@ -1254,6 +1263,55 @@ async def get_log_services() -> Dict[str, List[Dict[str, Any]]]: return {"services": services} + @self.app.get("/api/logs/history") + async def logs_history( + service: str = "pitrac", + file: Optional[str] = None, + before: Optional[int] = None, + lines: int = 200, + ) -> Dict[str, Any]: + """Scrollback for the per-run logs: page backwards across run files via a cursor.""" + lines = max(1, min(lines, 1000)) + + # journald scrollback is out of scope; only the file-backed pitrac log pages. + if service != "pitrac": + return {"lines": [], "next": None} + + log_dir = self.pitrac_manager.log_dir + run_logs = await asyncio.to_thread(list_run_logs, log_dir) # oldest → newest + if not run_logs: + return {"lines": [], "next": None} + + by_name = {p.name: p for p in run_logs} + if file is not None: + path = by_name.get(file) + if path is None: # unknown name → don't serve (also blocks traversal) + return {"lines": [], "next": None} + else: + path = run_logs[-1] + + def _prev_cursor() -> Optional[Dict[str, Any]]: + idx = run_logs.index(path) + if idx == 0: + return None # reached the very beginning + prev = run_logs[idx - 1] + return {"file": prev.name, "offset": prev.stat().st_size} + + if before is None: + result_lines, offset = await asyncio.to_thread(tail_lines, path, lines) + next_cursor = {"file": path.name, "offset": offset} if offset > 0 else _prev_cursor() + return {"lines": result_lines, "next": next_cursor} + + # Stale cursor: the truncate backstop shrank the file under us; tell the client + # to re-anchor from the tail rather than serving lines past the new end. + size = await asyncio.to_thread(lambda: path.stat().st_size) + if before > size: + return {"lines": [], "next": {"file": path.name, "offset": size}, "reset": True} + + result_lines, new_offset = await asyncio.to_thread(read_chunk_before, path, before, lines) + next_cursor = {"file": path.name, "offset": new_offset} if new_offset > 0 else _prev_cursor() + return {"lines": result_lines, "next": next_cursor} + async def _stream_service_logs(self, websocket: WebSocket, service: str) -> None: """Stream logs for a specific service via WebSocket""" try: @@ -1340,49 +1398,69 @@ async def _stream_systemd_logs(self, websocket: WebSocket, unit: str) -> None: logger.error(f"Error streaming systemd logs: {e}") async def _stream_file_logs(self, websocket: WebSocket, log_file: Path) -> None: - """Stream logs from a file""" + """Stream the active per-run log: anchor + historical tail, then follow new lines. + + Tracks the latest run file rather than a fixed path — a pitrac restart rolls a new + per-run log, so when latest_run_log changes mid-view we re-anchor on the new file. + """ + log_dir = self.pitrac_manager.log_dir try: - if not log_file.exists(): - await websocket.send_json({"message": f"Log file not found: {log_file}", "level": "warning"}) + current = await asyncio.to_thread(latest_run_log, log_dir) + if current is None: + current = self.pitrac_manager.log_file + if not current.exists(): + await websocket.send_json({"message": f"Log file not found: {current}", "level": "warning"}) return - with open(log_file, "r") as f: - lines = f.readlines() - recent = lines[-100:] if len(lines) > 100 else lines - for line in recent: - await websocket.send_json({"message": line.rstrip(), "historical": True}) - - follow_proc = await asyncio.create_subprocess_exec( - "tail", - "-f", - str(log_file), - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - ) - - if follow_proc.stdout: - async for line in follow_proc.stdout: + # Outer loop: each pass anchors on `current`, then follows it until the + # latest run file changes (restart) — then breaks out to re-resolve. + poll = 0.5 + while True: + lines, offset = await asyncio.to_thread(tail_lines, current, 100) + await websocket.send_json({"type": "anchor", "file": current.name, "offset": offset}) + for line in lines: + await websocket.send_json({"message": line, "historical": True}) + + follower = follow(current, poll_interval=poll) + switched = False + while not switched: try: - await websocket.send_json( - { - "message": line.decode("utf-8", errors="replace").rstrip(), - "historical": False, - } - ) - except WebSocketDisconnect: - follow_proc.terminate() - return - + # Bound the wait so an idle old file still lets us notice a restart. + line = await asyncio.wait_for(follower.__anext__(), timeout=poll + 0.25) + await websocket.send_json({"message": line, "historical": False}) + except asyncio.TimeoutError: + pass + latest = await asyncio.to_thread(latest_run_log, log_dir) + if latest is not None and latest != current: + current = latest + switched = True + await follower.aclose() + + except WebSocketDisconnect: + return except Exception as e: logger.error(f"Error streaming file logs: {e}") async def _maintenance_loop(self) -> None: while not self.shutdown_flag: await asyncio.sleep(300) + # Each backstop runs independently; one failing must not skip the others. try: await asyncio.to_thread(self.retention.prune) except Exception: logger.exception("retention prune failed; will retry next cycle") + try: + await asyncio.to_thread( + prune_run_logs, self.pitrac_manager.log_dir, self.pitrac_manager.log_dir_cap_bytes + ) + except Exception: + logger.exception("run-log dir prune failed; will retry next cycle") + try: + await asyncio.to_thread( + truncate_if_over, self.pitrac_manager.log_file, self.pitrac_manager.run_log_cap_bytes + ) + except Exception: + logger.exception("run-log truncate failed; will retry next cycle") async def startup_event(self) -> None: logger.info("Starting PiTrac Web Server...") diff --git a/Software/web-server/tests/test_logs_api.py b/Software/web-server/tests/test_logs_api.py index 72ae54c4..72f9fc95 100644 --- a/Software/web-server/tests/test_logs_api.py +++ b/Software/web-server/tests/test_logs_api.py @@ -1,8 +1,13 @@ +import asyncio +import os import pytest import time +from pathlib import Path from unittest.mock import patch from fastapi.testclient import TestClient +import log_files + @pytest.mark.unit class TestLogsAPI: @@ -274,3 +279,187 @@ async def mock_stream(websocket, unit): time.sleep(0.1) assert True + + +def _write_run_log(path: Path, prefix: str, count: int) -> None: + path.write_text("".join(f"{prefix}-{i}\n" for i in range(count))) + + +@pytest.fixture +def run_logs(server_instance, tmp_path): + """Point the pitrac_manager at a tmp log dir with an older + newer run file.""" + log_dir = tmp_path / "logs" + log_dir.mkdir() + older = log_dir / "pitrac-20260101-000000.log" + newer = log_dir / "pitrac-20260102-000000.log" + _write_run_log(older, "old", 120) + _write_run_log(newer, "new", 120) + + server_instance.pitrac_manager.log_dir = log_dir + server_instance.pitrac_manager.log_file = newer + server_instance.pitrac_manager.log_dir_cap_bytes = 10 * 1024 * 1024 + server_instance.pitrac_manager.run_log_cap_bytes = 10 * 1024 * 1024 + return {"dir": log_dir, "older": older, "newer": newer} + + +@pytest.mark.unit +class TestLogsHistoryAPI: + """Test the /api/logs/history scrollback endpoint""" + + def test_history_tail_of_latest(self, run_logs, app): + client = TestClient(app) + resp = client.get("/api/logs/history", params={"service": "pitrac", "lines": 50}) + assert resp.status_code == 200 + data = resp.json() + assert len(data["lines"]) == 50 + assert data["lines"][-1] == "new-119" + assert data["lines"][0] == "new-70" + assert data["next"]["file"] == run_logs["newer"].name + assert data["next"]["offset"] > 0 + + def test_history_walk_back_to_previous_file(self, run_logs, app): + client = TestClient(app) + newer_name = run_logs["newer"].name + older_name = run_logs["older"].name + + # Start at the tail, then page backwards within the newer file. + resp = client.get("/api/logs/history", params={"service": "pitrac", "lines": 50}) + cursor = resp.json()["next"] + collected = list(resp.json()["lines"]) + + guard = 0 + while cursor is not None and cursor["file"] == newer_name: + guard += 1 + assert guard < 20 + resp = client.get( + "/api/logs/history", + params={"service": "pitrac", "file": cursor["file"], "before": cursor["offset"], "lines": 50}, + ) + data = resp.json() + collected = data["lines"] + collected + cursor = data["next"] + + # All 120 lines of the newer file should be recovered, in order. + assert collected == [f"new-{i}" for i in range(120)] + + # Cursor has now crossed into the older file at its end. + assert cursor is not None + assert cursor["file"] == older_name + assert cursor["offset"] == run_logs["older"].stat().st_size + + # Page back through the older file until next is null (very beginning). + older_collected = [] + guard = 0 + while cursor is not None: + guard += 1 + assert guard < 20 + resp = client.get( + "/api/logs/history", + params={"service": "pitrac", "file": cursor["file"], "before": cursor["offset"], "lines": 50}, + ) + data = resp.json() + older_collected = data["lines"] + older_collected + cursor = data["next"] + + assert older_collected == [f"old-{i}" for i in range(120)] + + def test_history_systemd_service_empty(self, run_logs, app): + client = TestClient(app) + resp = client.get("/api/logs/history", params={"service": "pitrac-web"}) + assert resp.status_code == 200 + assert resp.json() == {"lines": [], "next": None} + + def test_history_invalid_file_not_served(self, run_logs, app): + client = TestClient(app) + resp = client.get( + "/api/logs/history", + params={"service": "pitrac", "file": "../../../etc/passwd", "before": 10, "lines": 50}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["lines"] == [] + assert data["next"] is None + + def test_history_stale_cursor_resets(self, run_logs, app): + client = TestClient(app) + newer = run_logs["newer"] + beyond = newer.stat().st_size + 100_000 + resp = client.get( + "/api/logs/history", + params={"service": "pitrac", "file": newer.name, "before": beyond, "lines": 50}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["lines"] == [] + assert data.get("reset") is True + assert data["next"]["file"] == newer.name + assert data["next"]["offset"] == newer.stat().st_size + + def test_history_lines_capped(self, run_logs, app): + client = TestClient(app) + resp = client.get("/api/logs/history", params={"service": "pitrac", "lines": 99999}) + # 120 lines exist; capping at 1000 just means we get them all without error. + assert resp.status_code == 200 + assert len(resp.json()["lines"]) == 120 + + +@pytest.mark.unit +class TestFollow: + """Test the subprocess-free log follower""" + + async def test_follow_yields_appended_lines(self, tmp_path): + path = tmp_path / "follow.log" + path.write_text("first\n") + + gen = log_files.follow(path, poll_interval=0.05) + # Prime the generator so it records the starting size before we append. + first = asyncio.ensure_future(gen.__anext__()) + await asyncio.sleep(0.1) + assert not first.done() + + with open(path, "a") as f: + f.write("second\n") + line = await asyncio.wait_for(first, timeout=2.0) + assert line == "second" + + await gen.aclose() + + async def test_follow_holds_back_partial_line(self, tmp_path): + path = tmp_path / "follow.log" + path.write_text("first\n") + + gen = log_files.follow(path, poll_interval=0.05) + pending = asyncio.ensure_future(gen.__anext__()) + await asyncio.sleep(0.1) + + # write a line in two chunks; the fragment must not be yielded on its own + with open(path, "a") as f: + f.write("half ") + await asyncio.sleep(0.1) + assert not pending.done() + + with open(path, "a") as f: + f.write("line\n") + line = await asyncio.wait_for(pending, timeout=2.0) + assert line == "half line" + + await gen.aclose() + + async def test_follow_resets_on_truncate(self, tmp_path): + path = tmp_path / "follow.log" + path.write_text("line-a\nline-b\n") + + gen = log_files.follow(path, poll_interval=0.05) + pending = asyncio.ensure_future(gen.__anext__()) + await asyncio.sleep(0.1) + assert not pending.done() + + # Truncate to zero (the marathon-run cap backstop), then write fresh content. + os.truncate(path, 0) + with open(path, "a") as f: + f.write("after-reset\n") + + line = await asyncio.wait_for(pending, timeout=2.0) + assert line == "after-reset" + + await gen.aclose() From 671af16bac88f8cbb2d593fadb8ff0771a2ed5e0 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 7 Jun 2026 21:31:24 -0400 Subject: [PATCH 012/156] scrollback on the logs page --- Software/web-server/static/js/logs.js | 138 ++++++++++++++++++++++---- 1 file changed, 120 insertions(+), 18 deletions(-) diff --git a/Software/web-server/static/js/logs.js b/Software/web-server/static/js/logs.js index 0f424ffb..3925d84a 100644 --- a/Software/web-server/static/js/logs.js +++ b/Software/web-server/static/js/logs.js @@ -4,8 +4,16 @@ let isPaused = false; let currentService = null; let logBuffer = []; const maxLogLines = 2000; +// Hard ceiling for scrollback growth; trims from the newest end so live-tail trimming stays unaffected +const maxTotalLines = 5000; let stats = { lines: 0, errors: 0, warnings: 0 }; +// Scrollback state — reset on every anchor message or service switch +let currentAnchor = null; +let oldestCursor = null; +let reachedStart = false; +let historyLoading = false; + async function loadServices() { try { const response = await fetch('/api/logs/services'); @@ -53,12 +61,43 @@ function changeService() { statusEl.className = 'service-status ' + status; statusEl.style.display = 'inline-flex'; + resetScrollbackState(null); clearLogs(); document.getElementById('logViewer').className = 'log-viewer loading'; connectWebSocket(currentService); } +function resetScrollbackState(anchor) { + currentAnchor = anchor; + oldestCursor = anchor ? { file: anchor.file, offset: anchor.offset } : null; + reachedStart = false; + historyLoading = false; +} + +// Shared line→div renderer used by both appendLog and history prepend +function makeLogEntry(content) { + const logEntry = document.createElement('div'); + logEntry.className = 'log-entry'; + + if (content.includes('ERROR') || content.includes('[error]')) { + logEntry.classList.add('error'); + } else if (content.includes('WARN') || content.includes('[warning]')) { + logEntry.classList.add('warning'); + } else if (content.includes('INFO') || content.includes('[info]')) { + logEntry.classList.add('info'); + } else if (content.includes('DEBUG') || content.includes('[debug]')) { + logEntry.classList.add('debug'); + } + + const logContent = document.createElement('span'); + logContent.className = 'log-content'; + logContent.textContent = content; + logEntry.appendChild(logContent); + + return logEntry; +} + function connectWebSocket(service) { disconnectWebSocket(); @@ -68,7 +107,7 @@ function connectWebSocket(service) { ws = new WebSocket(wsUrl); ws.onopen = () => { - console.log('WebSocket connected'); + console.warn('WebSocket connected'); updateConnectionStatus(true); ws.send(JSON.stringify({ service: service })); @@ -78,8 +117,26 @@ function connectWebSocket(service) { }; ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'anchor') { + const isRestart = currentAnchor !== null; + resetScrollbackState({ file: data.file, offset: data.offset }); + if (isRestart) { + // Insert a separator so the user knows pitrac restarted + const sep = document.createElement('div'); + sep.className = 'log-entry info'; + const sepContent = document.createElement('span'); + sepContent.className = 'log-content'; + sepContent.textContent = '— pitrac restarted —'; + sep.appendChild(sepContent); + document.getElementById('logViewer').appendChild(sep); + logBuffer.push(sep); + } + return; + } + if (!isPaused) { - const data = JSON.parse(event.data); appendLog(data); } }; @@ -90,7 +147,7 @@ function connectWebSocket(service) { }; ws.onclose = () => { - console.log('WebSocket disconnected'); + console.warn('WebSocket disconnected'); updateConnectionStatus(false); if (currentService) { @@ -128,20 +185,14 @@ function updateConnectionStatus(connected) { function appendLog(logData) { const viewer = document.getElementById('logViewer'); - const logEntry = document.createElement('div'); - logEntry.className = 'log-entry'; - const content = logData.message || logData.content || ''; + const logEntry = makeLogEntry(content); + + // Track error/warning stats on live append if (content.includes('ERROR') || content.includes('[error]')) { - logEntry.classList.add('error'); stats.errors++; } else if (content.includes('WARN') || content.includes('[warning]')) { - logEntry.classList.add('warning'); stats.warnings++; - } else if (content.includes('INFO') || content.includes('[info]')) { - logEntry.classList.add('info'); - } else if (content.includes('DEBUG') || content.includes('[debug]')) { - logEntry.classList.add('debug'); } if (logData.timestamp) { @@ -163,17 +214,13 @@ function appendLog(logData) { timestamp.textContent = ''; } - logEntry.appendChild(timestamp); + logEntry.insertBefore(timestamp, logEntry.firstChild); } - const logContent = document.createElement('span'); - logContent.className = 'log-content'; - logContent.textContent = content; - logEntry.appendChild(logContent); - viewer.appendChild(logEntry); logBuffer.push(logEntry); + // Trim oldest (front) when live tail grows past the cap if (logBuffer.length > maxLogLines) { const oldEntry = logBuffer.shift(); oldEntry.remove(); @@ -187,6 +234,54 @@ function appendLog(logData) { } } +async function loadOlderHistory() { + if (historyLoading || reachedStart || !oldestCursor || !currentService) return; + + historyLoading = true; + const viewer = document.getElementById('logViewer'); + const prevScrollHeight = viewer.scrollHeight; + + try { + const url = `/api/logs/history?service=${encodeURIComponent(currentService)}&file=${encodeURIComponent(oldestCursor.file)}&before=${oldestCursor.offset}&lines=200`; + const response = await fetch(url); + const data = await response.json(); + + if (data.reset) { + // Server says cursor is stale (file truncated) — clear and let live stream re-anchor + clearLogs(); + resetScrollbackState(null); + historyLoading = false; + return; + } + + const lines = data.lines || []; + if (lines.length > 0) { + const fragment = document.createDocumentFragment(); + const newEntries = lines.map(line => makeLogEntry(line)); + newEntries.forEach(entry => fragment.appendChild(entry)); + + viewer.insertBefore(fragment, viewer.firstChild); + + // Prepend to buffer (oldest first); trim from newest end if over ceiling + logBuffer.unshift(...newEntries); + while (logBuffer.length > maxTotalLines) { + const trimmed = logBuffer.pop(); + trimmed.remove(); + } + } + + oldestCursor = data.next || null; + if (data.next === null) reachedStart = true; + + // Restore scroll so the previously visible content stays in place + viewer.scrollTop = viewer.scrollHeight - prevScrollHeight; + } catch (err) { + console.error('Failed to load log history:', err); + } + + historyLoading = false; +} + function updateStats() { document.getElementById('lineCount').textContent = stats.lines; document.getElementById('errorCount').textContent = stats.errors; @@ -242,6 +337,13 @@ function downloadLogs() { document.addEventListener('DOMContentLoaded', () => { loadServices(); + + const viewer = document.getElementById('logViewer'); + viewer.addEventListener('scroll', () => { + if (viewer.scrollTop < 50) { + loadOlderHistory(); + } + }); }); window.addEventListener('beforeunload', () => { From bcfce7fab9bc7310bce1777c48e671f29230e03a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 8 Jun 2026 20:37:48 -0400 Subject: [PATCH 013/156] tighten log caps so the live file stays under the dir cap --- Software/web-server/configurations.json | 4 ++-- Software/web-server/server.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index d9918940..b3c01bf1 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -174,9 +174,9 @@ }, "runLogCapMB": { "displayName": "Run Log Cap MB", - "description": "Maximum size for a single per-run log file before truncation", + "description": "Maximum size for a single per-run log file before truncation; kept under the directory cap so the live file can't exceed it", "type": "number", - "default": 50, + "default": 20, "internal": true } }, diff --git a/Software/web-server/server.py b/Software/web-server/server.py index baf2f4ee..26e3a1cb 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1316,8 +1316,7 @@ async def _stream_service_logs(self, websocket: WebSocket, service: str) -> None """Stream logs for a specific service via WebSocket""" try: if service == "pitrac": - log_file = self.pitrac_manager.log_file - await self._stream_file_logs(websocket, log_file) + await self._stream_file_logs(websocket) elif service == "pitrac-web": await self._stream_systemd_logs(websocket, "pitrac-web") else: @@ -1397,7 +1396,7 @@ async def _stream_systemd_logs(self, websocket: WebSocket, unit: str) -> None: except Exception as e: logger.error(f"Error streaming systemd logs: {e}") - async def _stream_file_logs(self, websocket: WebSocket, log_file: Path) -> None: + async def _stream_file_logs(self, websocket: WebSocket) -> None: """Stream the active per-run log: anchor + historical tail, then follow new lines. Tracks the latest run file rather than a fixed path — a pitrac restart rolls a new From cb2faf1f55826f2e08bf57fa16d7269e80390062 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 10 Jun 2026 00:12:11 -0400 Subject: [PATCH 014/156] tidy up the history page theming and tailwind usage --- Software/web-server/static/js/history.js | 90 +++++++++++----------- Software/web-server/templates/history.html | 8 +- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/Software/web-server/static/js/history.js b/Software/web-server/static/js/history.js index c15dd1e7..ee8fdd86 100644 --- a/Software/web-server/static/js/history.js +++ b/Software/web-server/static/js/history.js @@ -1,6 +1,15 @@ // Shot history page let selectedSessionId = null; +function emptyState(msg, isError = false) { + const tone = isError ? 'text-error' : 'opacity-40'; + return `
${escapeHtml(msg)}
`; +} + +function loadingState() { + return '
'; +} + async function loadStorageUsage() { try { const resp = await fetch('/api/storage/usage'); @@ -26,42 +35,44 @@ async function loadSessions() { const sessions = await resp.json(); if (sessions.length === 0) { - container.innerHTML = '
No sessions yet.
'; + container.innerHTML = emptyState('No sessions yet.'); return; } - container.innerHTML = ''; + const list = document.createElement('ul'); + list.className = 'list bg-base-100 rounded-box'; + sessions.forEach(session => { - const card = document.createElement('div'); - card.className = 'card bg-base-300 border border-base-300 p-3 cursor-pointer hover:border-primary transition-colors'; - card.dataset.sessionId = session.id; + const row = document.createElement('li'); + row.className = 'list-row items-center cursor-pointer hover:bg-base-200 transition-colors'; + row.dataset.sessionId = session.id; const started = new Date(session.started_at).toLocaleString(); const label = session.label ? `
${escapeHtml(session.label)}
` : ''; - card.innerHTML = ` -
-
- ${label} -
${started}
-
${session.shot_count} shot${session.shot_count !== 1 ? 's' : ''}
-
- + row.innerHTML = ` +
+ ${label} +
${started}
+
${session.shot_count} shot${session.shot_count !== 1 ? 's' : ''}
+ `; - container.appendChild(card); + list.appendChild(row); }); + container.innerHTML = ''; + container.appendChild(list); + if (typeof lucide !== 'undefined') lucide.createIcons(); // re-select the previously selected session if it still exists if (selectedSessionId !== null) { - const existing = container.querySelector(`[data-session-id="${selectedSessionId}"]`); - if (existing) { - existing.classList.add('border-primary'); + if (container.querySelector(`[data-session-id="${selectedSessionId}"]`)) { + highlightSession(selectedSessionId); } else { selectedSessionId = null; clearShots(); @@ -69,30 +80,28 @@ async function loadSessions() { } } catch (err) { console.error('Failed to load sessions:', err); - container.innerHTML = '
Failed to load sessions.
'; + container.innerHTML = emptyState('Failed to load sessions.', true); } } function clearShots() { document.getElementById('shots-heading').textContent = 'Shots'; - document.getElementById('shots-list').innerHTML = - '
Select a session to view shots.
'; + document.getElementById('shots-list').innerHTML = emptyState('Select a session to view shots.'); clearDetail(); } function clearDetail() { - document.getElementById('shot-detail').innerHTML = - '
Select a shot to view details.
'; + document.getElementById('shot-detail').innerHTML = emptyState('Select a shot to view details.'); } async function loadShots(sessionId) { const container = document.getElementById('shots-list'); - container.innerHTML = '
Loading...
'; + container.innerHTML = loadingState(); try { const resp = await fetch(`/api/sessions/${sessionId}/shots`); if (!resp.ok) { - container.innerHTML = '
Session not found.
'; + container.innerHTML = emptyState('Session not found.', true); return; } @@ -100,7 +109,7 @@ async function loadShots(sessionId) { document.getElementById('shots-heading').textContent = `Shots (${shots.length})`; if (shots.length === 0) { - container.innerHTML = '
No shots in this session.
'; + container.innerHTML = emptyState('No shots in this session.'); return; } @@ -126,7 +135,7 @@ async function loadShots(sessionId) { const tbody = document.getElementById('shots-tbody'); shots.forEach(shot => { const tr = document.createElement('tr'); - tr.className = 'cursor-pointer hover'; + tr.className = 'cursor-pointer hover:bg-base-300'; tr.dataset.shotId = shot.id; const t = new Date(shot.created_at).toLocaleTimeString(); @@ -137,7 +146,7 @@ async function loadShots(sessionId) { tr.innerHTML = ` ${t} - ${escapeHtml(shot.result_type || '--')} + ${escapeHtml(shot.result_type || '--')} ${speed} ${carry} ${launch} @@ -149,18 +158,18 @@ async function loadShots(sessionId) { } catch (err) { console.error('Failed to load shots:', err); - container.innerHTML = '
Failed to load shots.
'; + container.innerHTML = emptyState('Failed to load shots.', true); } } async function loadShotDetail(shotId) { const container = document.getElementById('shot-detail'); - container.innerHTML = '
Loading...
'; + container.innerHTML = loadingState(); try { const resp = await fetch(`/api/shots/${shotId}`); if (!resp.ok) { - container.innerHTML = '
Shot not found.
'; + container.innerHTML = emptyState('Shot not found.', true); return; } @@ -219,7 +228,7 @@ async function loadShotDetail(shotId) { } catch (err) { console.error('Failed to load shot detail:', err); - container.innerHTML = '
Failed to load shot.
'; + container.innerHTML = emptyState('Failed to load shot.', true); } } @@ -254,22 +263,13 @@ function escapeHtml(str) { function highlightSession(sessionId) { document.querySelectorAll('#sessions-list [data-session-id]').forEach(el => { - if (!el.dataset.sessionId) return; - if (parseInt(el.dataset.sessionId, 10) === sessionId) { - el.classList.add('border-primary'); - } else { - el.classList.remove('border-primary'); - } + el.classList.toggle('bg-primary/10', parseInt(el.dataset.sessionId, 10) === sessionId); }); } function highlightShot(shotId) { document.querySelectorAll('#shots-tbody tr').forEach(tr => { - if (parseInt(tr.dataset.shotId, 10) === shotId) { - tr.classList.add('active'); - } else { - tr.classList.remove('active'); - } + tr.classList.toggle('bg-primary/10', parseInt(tr.dataset.shotId, 10) === shotId); }); } @@ -288,7 +288,7 @@ document.addEventListener('DOMContentLoaded', () => { } const card = e.target.closest('[data-session-id]'); - if (!card || card.classList.contains('delete-session-btn')) return; + if (!card) return; const sid = parseInt(card.dataset.sessionId, 10); selectedSessionId = sid; diff --git a/Software/web-server/templates/history.html b/Software/web-server/templates/history.html index e4b69bed..1868fedf 100644 --- a/Software/web-server/templates/history.html +++ b/Software/web-server/templates/history.html @@ -23,8 +23,8 @@

Shot History

Sessions

-
-
Loading...
+
+
@@ -33,7 +33,7 @@

Sessions

Shots

-
+
Select a session to view shots.
@@ -43,7 +43,7 @@

Shot Detail

-
+
Select a shot to view details.
From 68b056920da5c796f5e55c76ccb2f40e2e1535c0 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 11 Jun 2026 22:08:27 -0400 Subject: [PATCH 015/156] tidy up the logs page tailwind usage and wire handlers properly --- Software/web-server/static/js/logs.js | 5 +++++ Software/web-server/templates/logs.html | 10 +++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Software/web-server/static/js/logs.js b/Software/web-server/static/js/logs.js index 3925d84a..3777b5e3 100644 --- a/Software/web-server/static/js/logs.js +++ b/Software/web-server/static/js/logs.js @@ -338,6 +338,11 @@ function downloadLogs() { document.addEventListener('DOMContentLoaded', () => { loadServices(); + document.getElementById('serviceSelect').addEventListener('change', changeService); + document.getElementById('pauseButton').addEventListener('click', togglePause); + document.getElementById('clearButton').addEventListener('click', clearLogs); + document.getElementById('downloadButton').addEventListener('click', downloadLogs); + const viewer = document.getElementById('logViewer'); viewer.addEventListener('scroll', () => { if (viewer.scrollTop < 50) { diff --git a/Software/web-server/templates/logs.html b/Software/web-server/templates/logs.html index d96884f7..cc286ee7 100644 --- a/Software/web-server/templates/logs.html +++ b/Software/web-server/templates/logs.html @@ -7,7 +7,7 @@ {% endblock %} {% block content %} -
+
@@ -16,7 +16,7 @@
-
@@ -34,15 +34,15 @@
- - - From fb5a63f0ad5b5f80fefc00eba6a902e197a859f9 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 12 Jun 2026 20:49:57 -0400 Subject: [PATCH 016/156] fix dead daisyui classes, broken theme vars, and stale image fallback across pages --- Software/web-server/static/css/dashboard.css | 4 ++-- Software/web-server/static/css/pitrac.css | 2 +- Software/web-server/static/js/calibration.js | 16 +++++++++------- Software/web-server/static/js/dashboard.js | 5 ----- Software/web-server/templates/calibration.html | 2 +- Software/web-server/templates/config.html | 2 +- Software/web-server/templates/update.html | 2 +- 7 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Software/web-server/static/css/dashboard.css b/Software/web-server/static/css/dashboard.css index 6d7b3d36..5c6dbe84 100644 --- a/Software/web-server/static/css/dashboard.css +++ b/Software/web-server/static/css/dashboard.css @@ -53,9 +53,9 @@ animation: pulse 2s infinite; } -.status-strip.stabilizing { border-left-color: #fbbf24; } +.status-strip.stabilizing { border-left-color: var(--color-warning); } .status-strip.stabilizing .ball-icon { - background-color: #fbbf24; + background-color: var(--color-warning); animation: bounce 1s infinite; } diff --git a/Software/web-server/static/css/pitrac.css b/Software/web-server/static/css/pitrac.css index 8de82135..5bb4cda7 100644 --- a/Software/web-server/static/css/pitrac.css +++ b/Software/web-server/static/css/pitrac.css @@ -84,7 +84,7 @@ html[data-theme="light"] { /* Active theme button highlight */ .theme-btn.active { background: linear-gradient(135deg, oklch(59% 0.17 270), oklch(47% 0.17 300)); - color: white !important; + color: var(--color-primary-content) !important; border-color: transparent; } diff --git a/Software/web-server/static/js/calibration.js b/Software/web-server/static/js/calibration.js index 73936155..edce6c4b 100644 --- a/Software/web-server/static/js/calibration.js +++ b/Software/web-server/static/js/calibration.js @@ -106,10 +106,12 @@ class CalibrationManager { const statusElement = document.getElementById('pitrac-status'); if (status.running) { statusElement.textContent = 'Running'; - statusElement.style.color = '#4CAF50'; + statusElement.classList.add('text-success'); + statusElement.classList.remove('text-error'); } else { statusElement.textContent = 'Stopped'; - statusElement.style.color = '#f44336'; + statusElement.classList.add('text-error'); + statusElement.classList.remove('text-success'); } } } catch (error) { @@ -430,11 +432,11 @@ class CalibrationManager { } if (details.length > 0) { - detailsDiv.innerHTML = `${details.join(' | ')}`; + detailsDiv.innerHTML = `${details.join(' | ')}`; } progressBar.style.width = '100%'; - progressBar.style.background = '#f44336'; + progressBar.style.background = 'var(--color-error)'; statusText.textContent = 'Failed'; } } else { @@ -447,7 +449,7 @@ class CalibrationManager { const progressBar = document.getElementById(`${camera}-progress`); const statusText = document.getElementById(`${camera}-status`); progressBar.style.width = '100%'; - progressBar.style.background = '#f44336'; + progressBar.style.background = 'var(--color-error)'; statusText.textContent = 'Error'; } } @@ -801,7 +803,7 @@ class CalibrationManager { // Show results const resultArea = document.getElementById('strobe-result-area'); const resultCard = document.getElementById('strobe-result-card'); - resultCard.style.borderColor = 'var(--success)'; + resultCard.style.borderColor = 'var(--color-success)'; document.getElementById('strobe-result-title').textContent = 'Calibration Successful'; if (status.dac_setting !== undefined) { @@ -827,7 +829,7 @@ class CalibrationManager { this.loadStrobeSettings(); document.getElementById('strobe-progress-fill').style.width = '100%'; - document.getElementById('strobe-progress-fill').style.background = 'var(--error)'; + document.getElementById('strobe-progress-fill').style.background = 'var(--color-error)'; document.getElementById('strobe-state').textContent = 'Failed'; document.getElementById('strobe-progress-message').textContent = status.message || 'Calibration failed'; diff --git a/Software/web-server/static/js/dashboard.js b/Software/web-server/static/js/dashboard.js index 24b1dbd3..04d8def6 100644 --- a/Software/web-server/static/js/dashboard.js +++ b/Software/web-server/static/js/dashboard.js @@ -212,11 +212,6 @@ document.addEventListener('DOMContentLoaded', () => { updateBallStatus(null, null, false); - // Best-effort pre-per-shot-dir fallback: try to restore the last shot image on refresh - const img = new Image(); - img.onload = () => handleImageReady('ball_exposure_candidates.png'); - img.src = '/images/ball_exposure_candidates.png?t=' + Date.now(); - if (window.checkPiTracStatus) { originalCheckPiTracStatus = window.checkPiTracStatus; window.checkPiTracStatus = dashboardCheckPiTracStatus; diff --git a/Software/web-server/templates/calibration.html b/Software/web-server/templates/calibration.html index 854b6262..2c0e2d41 100644 --- a/Software/web-server/templates/calibration.html +++ b/Software/web-server/templates/calibration.html @@ -476,7 +476,7 @@

Strobe Calibration

- diff --git a/Software/web-server/templates/config.html b/Software/web-server/templates/config.html index c9785077..3cbc61c5 100644 --- a/Software/web-server/templates/config.html +++ b/Software/web-server/templates/config.html @@ -47,7 +47,7 @@

Categories

diff --git a/Software/web-server/templates/update.html b/Software/web-server/templates/update.html index d395241f..6b0a3508 100644 --- a/Software/web-server/templates/update.html +++ b/Software/web-server/templates/update.html @@ -33,7 +33,7 @@

-
From f49310eb203d2d256133a507e976e6dedd57e8e8 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 13 Jun 2026 19:59:00 -0400 Subject: [PATCH 017/156] use real daisyui modal, badge, and buttons on the testing page --- Software/web-server/static/css/testing.css | 53 ---------------------- Software/web-server/static/js/testing.js | 14 +++--- Software/web-server/templates/testing.html | 12 ++--- 3 files changed, 13 insertions(+), 66 deletions(-) diff --git a/Software/web-server/static/css/testing.css b/Software/web-server/static/css/testing.css index d85f930c..4d78f857 100644 --- a/Software/web-server/static/css/testing.css +++ b/Software/web-server/static/css/testing.css @@ -46,16 +46,6 @@ font-weight: 600; } -.sudo-badge { - background: var(--color-error); - color: white; - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; -} - .tool-description { margin: 0 0 1rem 0; opacity: 0.6; @@ -72,22 +62,6 @@ flex: 1; } -/* JS creates stop buttons with .btn-danger (not daisyUI's btn-error) */ -.btn-danger { - background: var(--color-error); - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: var(--radius-field, 0.25rem); - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; -} - -.btn-danger:hover { - opacity: 0.85; -} - /* Terminal-style output panel */ .output-content { background: #1e1e1e; @@ -154,33 +128,6 @@ gap: 1rem; } -/* Div-based modal (JS controls via display: block/none, not ) */ -#testModal { - position: fixed; - inset: 0; - z-index: 999; - display: flex; - justify-content: center; - align-items: center; -} - -#testModal .modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - z-index: -1; - cursor: pointer; -} - -#testModal .modal-box { - background: var(--color-base-200); - border-radius: 1rem; - padding: 1.5rem; - max-height: 90vh; - overflow: auto; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4); -} - /* Upload drag-over state (JS toggles this) */ .upload-area.drag-over { border-color: var(--color-primary) !important; diff --git a/Software/web-server/static/js/testing.js b/Software/web-server/static/js/testing.js index ffe05ecc..70dbb73e 100644 --- a/Software/web-server/static/js/testing.js +++ b/Software/web-server/static/js/testing.js @@ -41,16 +41,16 @@ function createToolCard(tool) { card.innerHTML = `

${tool.name}

- ${tool.requires_sudo ? 'sudo' : ''} + ${tool.requires_sudo ? 'sudo' : ''}

${tool.description}

- - ${isRunning ? `` : ''} + ${isRunning ? `` : ''}
`; @@ -83,9 +83,9 @@ async function runTool(toolId) { runBtn.textContent = 'Running...'; const actionsDiv = card.querySelector('.tool-actions'); - if (!actionsDiv.querySelector('.btn-danger')) { + if (!actionsDiv.querySelector('.stop-btn')) { const stopBtn = document.createElement('button'); - stopBtn.className = 'btn btn-danger'; + stopBtn.className = 'btn btn-error btn-sm stop-btn'; stopBtn.textContent = 'Stop'; stopBtn.onclick = () => stopTool(toolId); actionsDiv.appendChild(stopBtn); @@ -156,7 +156,7 @@ function updateToolCard(toolId) { if (!card) return; const runBtn = card.querySelector('.run-btn'); - const stopBtn = card.querySelector('.btn-danger'); + const stopBtn = card.querySelector('.stop-btn'); card.classList.remove('running'); runBtn.disabled = false; @@ -247,7 +247,7 @@ function showImageResult(toolId, imageUrl) {
`; - modal.style.display = 'block'; + modal.showModal(); } function showError(message) { diff --git a/Software/web-server/templates/testing.html b/Software/web-server/templates/testing.html index a2c7873b..b4ea089a 100644 --- a/Software/web-server/templates/testing.html +++ b/Software/web-server/templates/testing.html @@ -92,15 +92,15 @@

Test Output

- - From d3e5191cc293ea5e9b5a5fbb0a465438e21ddca2 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 15 Jun 2026 09:59:52 -0400 Subject: [PATCH 020/156] make config use the real menu, badges, table and alert components --- Software/web-server/static/css/config.css | 118 ++-------------------- Software/web-server/static/js/config.js | 118 ++++++++++------------ 2 files changed, 65 insertions(+), 171 deletions(-) diff --git a/Software/web-server/static/css/config.css b/Software/web-server/static/css/config.css index 0836dc95..92fe730b 100644 --- a/Software/web-server/static/css/config.css +++ b/Software/web-server/static/css/config.css @@ -7,37 +7,19 @@ .status-message.warning { color: var(--color-warning); } .status-message.info { color: var(--color-info); } -/* Category sidebar items — JS creates these dynamically */ -.category-item { - padding: 0.6rem 1rem; - margin-bottom: 0.125rem; - border-radius: 0.5rem; - cursor: pointer; - color: color-mix(in oklch, var(--color-base-content) 60%, transparent); - transition: background 0.15s, color 0.15s; -} - -.category-item:hover { - background: var(--color-base-300); - color: var(--color-base-content); -} - -.category-item.active { - background: var(--color-primary); - color: var(--color-primary-content); -} - -.category-item[data-category="Basic"] { +/* Category sidebar — DaisyUI menu handles padding/hover/active. + Keep only the "Basic" emphasis + asterisk marker on the menu link. */ +#categoryList a[data-category="Basic"] { font-weight: 600; } -.category-item[data-category="Basic"]::after { +#categoryList a[data-category="Basic"]::after { content: "*"; - float: right; + margin-left: auto; color: var(--color-warning); } -.category-item.active[data-category="Basic"]::after { +#categoryList a.menu-active[data-category="Basic"]::after { color: var(--color-primary-content); } @@ -218,40 +200,10 @@ select.config-input { .input-wrapper .config-input { padding-right: 2rem; } .input-wrapper select.config-input { padding-right: 2.5rem; } +/* Clear-value button is a daisyUI btn; only the overlay position is custom */ .clear-value-btn { position: absolute; right: 0.4rem; - background: transparent; - border: none; - color: color-mix(in oklch, var(--color-base-content) 35%, transparent); - font-size: 1.2rem; - cursor: pointer; - width: 1.4rem; - height: 1.4rem; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: background 0.15s, color 0.15s; -} -.clear-value-btn:hover { - background: color-mix(in oklch, var(--color-error) 10%, transparent); - color: var(--color-error); -} - -/* Default badge */ -.default-badge { - display: inline-block; - padding: 0.1rem 0.35rem; - margin-left: 0.4rem; - background: var(--color-base-300); - color: color-mix(in oklch, var(--color-base-content) 45%, transparent); - font-size: 0.6rem; - font-weight: 700; - letter-spacing: 0.05em; - border-radius: 0.2rem; - border: 1px solid var(--color-base-300); - vertical-align: middle; } /* Restart indicator */ @@ -262,26 +214,12 @@ select.config-input { cursor: help; } -/* Config actions column */ +/* Config actions column — buttons are daisyUI .btn, just lay them out */ .config-actions { display: flex; gap: 0.4rem; align-items: center; } -.config-actions button { - padding: 0.3rem 0.6rem; - background: color-mix(in oklch, var(--color-base-300) 50%, transparent); - border: 1px solid var(--color-base-300); - border-radius: 0.3rem; - color: color-mix(in oklch, var(--color-base-content) 60%, transparent); - font-size: 0.75rem; - cursor: pointer; - transition: background 0.15s; -} -.config-actions button:hover { - background: var(--color-base-300); - color: var(--color-base-content); -} /* Validation errors */ .validation-error { @@ -305,19 +243,7 @@ select.config-input { align-items: center; } -.settings-counter span { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.2rem 0.6rem; - border-radius: 1rem; - background: var(--color-base-100); - cursor: help; -} - -.counter-custom { border: 1px solid var(--color-primary); color: var(--color-primary); } -.counter-default { border: 1px solid color-mix(in oklch, var(--color-base-content) 30%, transparent); color: color-mix(in oklch, var(--color-base-content) 40%, transparent); } -.counter-total { border: 1px solid var(--color-base-300); color: color-mix(in oklch, var(--color-base-content) 50%, transparent); } +/* Counter pills are daisyUI badges; the container just lays them out. */ /* Loading overlay — use specific class to avoid colliding with daisyUI's .loading */ .config-loading-overlay { @@ -350,22 +276,7 @@ select.config-input { .diff-header { margin-bottom: 1.25rem; text-align: center; } .diff-summary { opacity: 0.5; font-size: 0.85rem; margin-top: 0.4rem; } -.diff-table { - width: 100%; - border-collapse: collapse; - margin: 0.75rem 0; -} -.diff-table th { - background: color-mix(in oklch, var(--color-base-300) 50%, transparent); - padding: 0.6rem; - text-align: left; - font-weight: 600; - border-bottom: 2px solid var(--color-base-300); -} -.diff-table td { - padding: 0.6rem; - border-bottom: 1px solid color-mix(in oklch, var(--color-base-300) 50%, transparent); -} +/* Table structure handled by daisyUI .table .table-sm */ .diff-key-name { font-weight: 500; } .diff-key-path { @@ -390,16 +301,9 @@ select.config-input { border-top: 1px solid color-mix(in oklch, var(--color-base-300) 50%, transparent); } -/* Dependency warning toast */ +/* Dependency warning toast (daisyUI alert inside a toast container) */ .dependency-warning { animation: slideIn 0.3s ease; } -/* Small button variant used by JS (detect, reset, etc.) */ -.btn-small { - padding: 0.3rem 0.7rem; - font-size: 0.8rem; - white-space: nowrap; -} - /* Animations */ @keyframes slideIn { from { opacity: 0; transform: translateX(100%); } diff --git a/Software/web-server/static/js/config.js b/Software/web-server/static/js/config.js index 60df8a2a..b21dc9c7 100644 --- a/Software/web-server/static/js/config.js +++ b/Software/web-server/static/js/config.js @@ -105,18 +105,24 @@ async function loadConfiguration() { } } +// Build a DaisyUI menu item (
  • ) for a category +function createCategoryMenuItem(category, label) { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.dataset.category = category; + a.textContent = label; + a.onclick = () => selectCategory(category); + li.appendChild(a); + return li; +} + // Render category list function renderCategories() { const categoryList = document.getElementById('categoryList'); categoryList.innerHTML = ''; // Add "All Settings" option first - const allItem = document.createElement('li'); - allItem.className = 'category-item'; - allItem.dataset.category = 'all'; - allItem.textContent = 'All Settings'; - allItem.onclick = () => selectCategory('all'); - categoryList.appendChild(allItem); + categoryList.appendChild(createCategoryMenuItem('all', 'All Settings')); // Add each category with its settings count Object.keys(categories).forEach(category => { @@ -126,12 +132,9 @@ function renderCategories() { const totalCount = basicCount + advancedCount; if (totalCount > 0) { - const li = document.createElement('li'); - li.className = 'category-item'; - li.dataset.category = category; - li.textContent = `${category} (${totalCount})`; - li.onclick = () => selectCategory(category); - categoryList.appendChild(li); + categoryList.appendChild( + createCategoryMenuItem(category, `${category} (${totalCount})`) + ); } }); @@ -144,10 +147,10 @@ function renderCategories() { // Select category function selectCategory(category) { // Update active category - document.querySelectorAll('.category-item').forEach(item => { - item.classList.remove('active'); + document.querySelectorAll('#categoryList a[data-category]').forEach(item => { + item.classList.remove('menu-active'); if (item.dataset.category === category) { - item.classList.add('active'); + item.classList.add('menu-active'); } }); @@ -345,7 +348,7 @@ function createConfigItem(key, value, defaultValue, isModified) { // Note: isUserSet already defined above if (!isUserSet) { - labelHTML += ' DEFAULT'; + labelHTML += ' DEFAULT'; } if (metadata.requiresRestart) { @@ -367,7 +370,7 @@ function createConfigItem(key, value, defaultValue, isModified) { inputContainer.className = 'input-container'; const input = createInput(key, value, defaultValue, isUserSet); - input.className = 'config-input'; + input.classList.add('config-input'); input.dataset.key = key; input.dataset.original = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : String(value); input.dataset.default = (typeof defaultValue === 'object' && defaultValue !== null) ? JSON.stringify(defaultValue) : String(defaultValue); @@ -385,7 +388,7 @@ function createConfigItem(key, value, defaultValue, isModified) { if (isUserSet || isModified) { const clearBtn = document.createElement('button'); - clearBtn.className = 'clear-value-btn'; + clearBtn.className = 'clear-value-btn btn btn-ghost btn-xs btn-circle'; clearBtn.innerHTML = '×'; clearBtn.title = 'Reset to default'; clearBtn.onclick = (e) => { @@ -411,7 +414,7 @@ function createConfigItem(key, value, defaultValue, isModified) { if (!inputWrapper.querySelector('.clear-value-btn')) { const clearBtn = document.createElement('button'); - clearBtn.className = 'clear-value-btn'; + clearBtn.className = 'clear-value-btn btn btn-ghost btn-xs btn-circle'; clearBtn.innerHTML = '×'; clearBtn.title = 'Reset to default'; clearBtn.onclick = (e) => { @@ -435,14 +438,11 @@ function createConfigItem(key, value, defaultValue, isModified) { if (key === 'cameras.slot1.type' || key === 'cameras.slot2.type' || key === 'cameras.slot1_type' || key === 'cameras.slot2_type') { - inputContainer.style.display = 'flex'; - inputContainer.style.alignItems = 'center'; - inputContainer.style.gap = '0.75rem'; + inputContainer.classList.add('flex', 'items-center', 'gap-3'); const detectBtn = document.createElement('button'); - detectBtn.className = 'btn btn-secondary btn-small'; + detectBtn.className = 'btn btn-secondary btn-xs shrink-0'; detectBtn.textContent = 'Detect'; - detectBtn.style.flexShrink = '0'; detectBtn.title = 'Auto-detect connected camera'; detectBtn.onclick = async () => { detectBtn.disabled = true; @@ -467,7 +467,7 @@ function createConfigItem(key, value, defaultValue, isModified) { // Only show reset button for user-set values (not for defaults) if (isUserSet && !isModified) { const resetBtn = document.createElement('button'); - resetBtn.className = 'btn btn-secondary btn-small'; + resetBtn.className = 'btn btn-secondary btn-xs'; resetBtn.textContent = 'Reset'; resetBtn.title = 'Reset to default value'; resetBtn.onclick = () => resetValue(key); @@ -548,9 +548,7 @@ function createInput(key, value, defaultValue, isUserSet) { const textarea = document.createElement('textarea'); textarea.value = JSON.stringify(value, null, 2); textarea.rows = 3; - textarea.style.width = '100%'; - textarea.style.fontFamily = 'Monaco, Menlo, monospace'; - textarea.style.fontSize = '0.875rem'; + textarea.classList.add('font-mono', 'text-sm', 'w-full'); if (!isUserSet) { textarea.placeholder = `Default: ${JSON.stringify(defaultValue, null, 2)}`; } @@ -661,7 +659,7 @@ async function handleValueChange(key, currentValue, originalValue) { const inputWrapper = item.querySelector('.input-wrapper'); if (inputWrapper && !inputWrapper.querySelector('.clear-value-btn')) { const clearBtn = document.createElement('button'); - clearBtn.className = 'clear-value-btn'; + clearBtn.className = 'clear-value-btn btn btn-ghost btn-xs btn-circle'; clearBtn.innerHTML = '×'; clearBtn.title = 'Reset to default'; clearBtn.onclick = (e) => { @@ -683,7 +681,7 @@ async function handleValueChange(key, currentValue, originalValue) { if (!badge) { const labelName = item.querySelector('.config-label-name'); if (labelName && !labelName.querySelector('.default-badge')) { - const badgeHtml = ' DEFAULT'; + const badgeHtml = ' DEFAULT'; labelName.insertAdjacentHTML('beforeend', badgeHtml); } } @@ -920,7 +918,7 @@ async function resetValue(key) { if (!badge) { const labelName = item.querySelector('.config-label-name'); if (labelName) { - const badgeHtml = ' DEFAULT'; + const badgeHtml = ' DEFAULT'; labelName.insertAdjacentHTML('beforeend', badgeHtml); } } @@ -1026,7 +1024,7 @@ async function showDiff() { diffHtml += `
    - +
    @@ -1048,7 +1046,7 @@ async function showDiff() { : ''; diffHtml += ` - + `; @@ -1250,30 +1248,22 @@ function checkDependencies(key, value) { } function showDependencyWarning(key, warnings) { - const message = `Changing ${key} affects:
    ` + warnings.join('
    '); - - const notification = document.createElement('div'); - notification.className = 'dependency-warning'; - notification.innerHTML = message; - notification.style.cssText = ` - position: fixed; - top: 80px; - right: 20px; - background: var(--warning-bg, #fef3c7); - color: var(--warning-text, #92400e); - padding: 1rem; - border-radius: 8px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); - max-width: 300px; - z-index: 1000; - animation: slideIn 0.3s ease; - `; - - document.body.appendChild(notification); + let container = document.getElementById('dependencyToast'); + if (!container) { + container = document.createElement('div'); + container.id = 'dependencyToast'; + container.className = 'toast toast-top toast-end'; + document.body.appendChild(container); + } + + const alert = document.createElement('div'); + alert.className = 'alert alert-warning dependency-warning max-w-xs'; + alert.innerHTML = `Changing ${key} affects:
    ${warnings.join('
    ')}
    `; + container.appendChild(alert); setTimeout(() => { - notification.style.animation = 'slideOut 0.3s ease'; - setTimeout(() => notification.remove(), 300); + alert.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => alert.remove(), 300); }, 5000); } @@ -1367,9 +1357,9 @@ function updateModifiedCount() { if (counterEl) { counterEl.innerHTML = ` - ${userSetCount} custom - ${defaultCount} defaults - ${totalSettings} total + ${userSetCount} custom + ${defaultCount} defaults + ${totalSettings} total `; } } @@ -1497,11 +1487,11 @@ async function detectAndSetCameras(targetKey = null) { showModal('Camera Detection Failed', `

    ${errorMsg}

    ` + '

    Warnings:

    ' + - '
      ' + + '
        ' + result.warnings.map(w => `
      • ${w}
      • `).join('') + '
      ' + - '

      Troubleshooting:

      ' + - '
        ' + + '

        Troubleshooting:

        ' + + '
          ' + '
        • Check ribbon cable connections and orientation
        • ' + '
        • Verify camera_auto_detect=1 in /boot/firmware/config.txt
        • ' + '
        • Power cycle the Raspberry Pi
        • ' + @@ -1516,8 +1506,8 @@ async function detectAndSetCameras(targetKey = null) { showModal('Connection Error', '

          Failed to connect to camera detection service.

          ' + `

          Error: ${error.message}

          ` + - '

          Please ensure:

          ' + - '
            ' + + '

            Please ensure:

            ' + + '
              ' + '
            • The PiTrac web service is running
            • ' + '
            • You have a stable network connection
            • ' + '
            • Try refreshing the page
            • ' + From 812c8cb1c5c3603e5458d41b8c190b6211040ace Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 16 Jun 2026 21:58:39 -0400 Subject: [PATCH 021/156] use daisyui steps, progress and alert on the calibration page --- .../web-server/static/css/calibration.css | 99 +------------------ Software/web-server/static/js/calibration.js | 62 ++++++------ .../web-server/templates/calibration.html | 45 +++------ 3 files changed, 44 insertions(+), 162 deletions(-) diff --git a/Software/web-server/static/css/calibration.css b/Software/web-server/static/css/calibration.css index 926cbf94..22d6fc2a 100644 --- a/Software/web-server/static/css/calibration.css +++ b/Software/web-server/static/css/calibration.css @@ -6,79 +6,7 @@ box-shadow: 0 0 12px oklch(59% 0.17 270 / 0.3); } -/* ---- Wizard steps ---- */ -.wizard-steps { - position: relative; - z-index: 0; -} - -/* Connecting line behind the circles */ -.wizard-line { - position: absolute; - top: 20px; - left: 10%; - right: 10%; - height: 2px; - background: var(--color-base-300); - z-index: 0; -} - -.wizard-steps .step { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - z-index: 1; - flex: 1; -} - -.step-circle { - width: 40px; - height: 40px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: 0.9rem; - background: var(--color-base-300); - color: var(--color-base-content); - border: 2px solid var(--color-base-300); - transition: all 0.3s; -} - -.step-label { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.6; - transition: all 0.3s; -} - -.wizard-steps .step.active .step-circle { - background: var(--color-primary); - color: var(--color-primary-content); - border-color: var(--color-primary); - box-shadow: 0 0 12px rgba(102, 126, 234, 0.4); -} - -.wizard-steps .step.active .step-label { - opacity: 1; - color: var(--color-primary); -} - -.wizard-steps .step.completed .step-circle { - background: var(--color-success); - color: var(--color-primary-content); - border-color: var(--color-success); -} - -.wizard-steps .step.completed .step-label { - opacity: 0.7; -} - -/* ---- Progress bars ---- */ +/* ---- Progress bars (distortion calibration) ---- */ div.progress-bar { display: block; width: 100%; @@ -124,31 +52,6 @@ div.progress-bar > .progress-fill { opacity: 0.5; } -/* ---- Ball status ---- */ -.ball-status { - padding: 0.75rem; - border-radius: 0.25rem; - font-size: 0.875rem; -} - -.ball-status.success { - background: oklch(70% 0.14 160 / 0.15); - border: 1px solid oklch(70% 0.14 160); - color: oklch(70% 0.14 160); -} - -.ball-status.error { - background: oklch(65% 0.19 25 / 0.15); - border: 1px solid oklch(65% 0.19 25); - color: oklch(65% 0.19 25); -} - -/* ---- Completion details ---- */ -.completion-details { - color: oklch(70% 0.14 160); - line-height: 1.4; -} - /* ---- Terminal log ---- */ .log-content { background: #000; diff --git a/Software/web-server/static/js/calibration.js b/Software/web-server/static/js/calibration.js index edce6c4b..2b05d42d 100644 --- a/Software/web-server/static/js/calibration.js +++ b/Software/web-server/static/js/calibration.js @@ -198,16 +198,7 @@ class CalibrationManager { document.getElementById(`step${stepNumber}`).style.display = 'block'; document.querySelectorAll('.step').forEach(step => { - const stepNum = parseInt(step.dataset.step); - if (stepNum < stepNumber) { - step.classList.add('completed'); - step.classList.remove('active'); - } else if (stepNum === stepNumber) { - step.classList.add('active'); - step.classList.remove('completed'); - } else { - step.classList.remove('active', 'completed'); - } + step.classList.toggle('step-primary', parseInt(step.dataset.step) <= stepNumber); }); this.currentStep = stepNumber; @@ -303,7 +294,7 @@ class CalibrationManager { const statusDiv = document.getElementById(`${camera}-ball-status`); if (result.ball_found) { - statusDiv.className = 'ball-status success'; + statusDiv.className = 'alert alert-success'; statusDiv.textContent = `Ball detected at position (${result.ball_info?.x || 0}, ${result.ball_info?.y || 0})`; this.ballVerified[camera] = true; @@ -315,7 +306,7 @@ class CalibrationManager { '✅ Ball placement verified! Ready to proceed with calibration.'; } } else { - statusDiv.className = 'ball-status error'; + statusDiv.className = 'alert alert-error'; statusDiv.textContent = 'Ball not detected - please adjust placement'; this.ballVerified[camera] = false; } @@ -363,7 +354,8 @@ class CalibrationManager { const statusText = document.getElementById(`${camera}-status`); const detailsDiv = document.getElementById(`${camera}-details`); - progressBar.style.width = '10%'; + progressBar.value = 10; + progressBar.classList.remove('progress-error'); statusText.textContent = 'Initializing...'; detailsDiv.innerHTML = ''; @@ -378,7 +370,7 @@ class CalibrationManager { if (response.ok) { const result = await response.json(); - progressBar.style.width = '50%'; + progressBar.value = 50; statusText.textContent = 'Calibrating...'; const finalResult = await this.pollForCompletion(camera); @@ -400,7 +392,7 @@ class CalibrationManager { detailsDiv.innerHTML = `${details.join(' | ')}`; - progressBar.style.width = '100%'; + progressBar.value = 100; statusText.textContent = 'Completed'; if (!this.calibrationResults) { @@ -435,8 +427,8 @@ class CalibrationManager { detailsDiv.innerHTML = `${details.join(' | ')}`; } - progressBar.style.width = '100%'; - progressBar.style.background = 'var(--color-error)'; + progressBar.value = 100; + progressBar.classList.add('progress-error'); statusText.textContent = 'Failed'; } } else { @@ -448,8 +440,8 @@ class CalibrationManager { const progressBar = document.getElementById(`${camera}-progress`); const statusText = document.getElementById(`${camera}-status`); - progressBar.style.width = '100%'; - progressBar.style.background = 'var(--color-error)'; + progressBar.value = 100; + progressBar.classList.add('progress-error'); statusText.textContent = 'Error'; } } @@ -467,7 +459,7 @@ class CalibrationManager { const statusText = document.getElementById(`${camera}-status`); if (cameraStatus && cameraStatus.progress) { - progressBar.style.width = `${cameraStatus.progress}%`; + progressBar.value = parseFloat(cameraStatus.progress); } if (cameraStatus && cameraStatus.message) { @@ -602,14 +594,20 @@ class CalibrationManager { placeholder.style.display = 'flex'; }); - document.querySelectorAll('.ball-status').forEach(status => { - status.textContent = ''; - status.className = 'ball-status'; + ['camera1-ball-status', 'camera2-ball-status'].forEach(id => { + const status = document.getElementById(id); + if (status) { + status.textContent = ''; + status.className = ''; + } }); - document.querySelectorAll('.progress-fill').forEach(bar => { - bar.style.width = '0%'; - bar.style.background = ''; + ['camera1-progress', 'camera2-progress'].forEach(id => { + const bar = document.getElementById(id); + if (bar) { + bar.value = 0; + bar.classList.remove('progress-error'); + } }); } @@ -722,8 +720,8 @@ class CalibrationManager { // Reset and show progress area, hide stale results document.getElementById('strobe-progress-area').style.display = 'block'; document.getElementById('strobe-result-area').style.display = 'none'; - document.getElementById('strobe-progress-fill').style.width = '0%'; - document.getElementById('strobe-progress-fill').style.background = ''; + document.getElementById('strobe-progress-fill').value = 0; + document.getElementById('strobe-progress-fill').classList.remove('progress-error'); document.getElementById('strobe-progress-message').textContent = 'Starting...'; document.getElementById('strobe-state').textContent = 'Running'; @@ -768,7 +766,7 @@ class CalibrationManager { const progressMsg = document.getElementById('strobe-progress-message'); if (status.progress !== undefined) { - progressFill.style.width = status.progress + '%'; + progressFill.value = parseFloat(status.progress); } if (status.message) { progressMsg.textContent = status.message; @@ -797,7 +795,7 @@ class CalibrationManager { btn.textContent = 'Recalibrate'; cancelBtn.style.display = 'none'; - document.getElementById('strobe-progress-fill').style.width = '100%'; + document.getElementById('strobe-progress-fill').value = 100; document.getElementById('strobe-state').textContent = 'Complete'; // Show results @@ -828,8 +826,8 @@ class CalibrationManager { cancelBtn.style.display = 'none'; this.loadStrobeSettings(); - document.getElementById('strobe-progress-fill').style.width = '100%'; - document.getElementById('strobe-progress-fill').style.background = 'var(--color-error)'; + document.getElementById('strobe-progress-fill').value = 100; + document.getElementById('strobe-progress-fill').classList.add('progress-error'); document.getElementById('strobe-state').textContent = 'Failed'; document.getElementById('strobe-progress-message').textContent = status.message || 'Calibration failed'; diff --git a/Software/web-server/templates/calibration.html b/Software/web-server/templates/calibration.html index 2c0e2d41..c12cf4fa 100644 --- a/Software/web-server/templates/calibration.html +++ b/Software/web-server/templates/calibration.html @@ -183,25 +183,12 @@

              Board Positions

              {# -- Wizard step indicators -- #} -
              -
              -
              -
              1
              -
              Setup
              -
              -
              -
              2
              -
              Verify
              -
              -
              -
              3
              -
              Calibrate
              -
              -
              -
              4
              -
              Complete
              -
              -
              +
                +
              • Setup
              • +
              • Verify
              • +
              • Calibrate
              • +
              • Complete
              • +
              {# ---- Step 1: Setup ---- #}
              @@ -281,7 +268,7 @@

              Camera 1 - Tee Camera (Top/Angled or Straight)< Check Ball Location

              -
              +
              @@ -303,7 +290,7 @@

              Camera 2 - Flight Camera (Bottom/Straight)

              Check Ball Location -
              +
              @@ -353,19 +340,15 @@

              Calibration in Progress...

              Camera 1

              -
              -
              -
              +
              Waiting...
              -
              +

              Camera 2

              -
              -
              -
              +
              Waiting...
              -
              +
              @@ -500,9 +483,7 @@

              Strobe Calibration

              {# Progress #} From 36b2ffd3ec2ef7df0ab5550da0b90f817c2c8536 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 18 Jun 2026 20:10:37 -0400 Subject: [PATCH 022/156] split out the merged config builder so it can be served, not just written --- Software/web-server/config_manager.py | 53 ++++++++++--------- .../web-server/tests/test_config_manager.py | 26 +++++++++ 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index 6561daab..61d2b1c3 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -574,6 +574,32 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: return True, "" + def build_generated_config(self) -> Dict[str, Any]: + """Build the merged config dict served to pitrac_lm (defaults + calibration + user settings, + excluding cli/env-passed settings).""" + config = {} + metadata = self.load_configurations_metadata() + settings_metadata = metadata.get("settings", {}) + + if not settings_metadata: + raise RuntimeError("No settings found in configurations metadata") + + json_settings_count = 0 + for key, setting_info in settings_metadata.items(): + passed_via = setting_info.get("passedVia", "json") + if passed_via in ["cli", "environment"]: + continue + + value = self.get_config(key) + if value is not None: + self._set_nested_json(config, key, value) + json_settings_count += 1 + + if json_settings_count == 0: + raise RuntimeError("No JSON settings found to generate config") + + return config + def generate_golf_sim_config(self) -> Path: """Generate golf_sim_config.json from configurations metadata and user settings @@ -589,37 +615,14 @@ def generate_golf_sim_config(self) -> Path: RuntimeError: If generation fails """ try: - config = {} - metadata = self.load_configurations_metadata() - settings_metadata = metadata.get("settings", {}) - - if not settings_metadata: - raise RuntimeError("No settings found in configurations metadata") - - # Process all settings and build the JSON structure - json_settings_count = 0 - for key, setting_info in settings_metadata.items(): - # Skip non-JSON routed settings - passed_via = setting_info.get("passedVia", "json") # Default to json if not specified - if passed_via in ["cli", "environment"]: - continue - - # Get the merged value (default + calibration + user override) - value = self.get_config(key) - if value is not None: - # Build nested structure from dot notation key - self._set_nested_json(config, key, value) - json_settings_count += 1 - - if json_settings_count == 0: - raise RuntimeError("No JSON settings found to generate config") + config = self.build_generated_config() # Save to generated location generated_path = self.user_settings_path.parent / "generated_golf_sim_config.json" if not self._save_json(generated_path, config): raise RuntimeError(f"Failed to save generated config to {generated_path}") - logger.info(f"Generated golf_sim_config.json with {json_settings_count} settings at {generated_path}") + logger.info(f"Generated golf_sim_config.json with {len(config.get('gs_config', {}))} top-level gs_config keys at {generated_path}") return generated_path except Exception as e: diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index 8b962b83..91b9c136 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -648,3 +648,29 @@ def test_callbacks_fire_for_each_key(self, config_manager): "gs_config.cameras.kCamera1DistortionVector": [0.0] * 5, }) assert any(k == "gs_config.cameras.kCamera1CalibrationMatrix" for k, _ in seen) + + +class TestBuildGeneratedConfig: + """Tests for the extracted config dict builder.""" + + @pytest.fixture + def temp_config_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + @pytest.fixture + def config_manager(self, temp_config_dir): + manager = ConfigManager() + manager.user_settings_path = temp_config_dir / "user_settings.json" + manager.calibration_data_path = temp_config_dir / "calibration_data.json" + return manager + + def test_build_generated_config_returns_merged_dict(self, config_manager): + cfg = config_manager.build_generated_config() + assert isinstance(cfg, dict) + assert "gs_config" in cfg + + def test_generate_golf_sim_config_matches_builder(self, config_manager): + import json + path = config_manager.generate_golf_sim_config() + assert json.loads(path.read_text()) == config_manager.build_generated_config() From 89ffa699de96aec13f0b0e0192db55b4638563a5 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 18 Jun 2026 21:09:54 -0400 Subject: [PATCH 023/156] serve the live merged config at /api/internal/config --- Software/web-server/server.py | 5 +++++ Software/web-server/tests/test_api_endpoints.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 26e3a1cb..9a02ed80 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -336,6 +336,11 @@ async def receive_image_ready(request: Request) -> Dict[str, str]: }) return {"status": "ok"} + @self.app.get("/api/internal/config") + async def internal_config() -> Dict[str, Any]: + """Merged config consumed by pitrac_lm at startup (replaces the generated JSON file).""" + return await asyncio.to_thread(self.config_manager.build_generated_config) + @self.app.get("/health") async def health_check() -> Dict[str, Union[str, bool, int]]: pitrac_running = False diff --git a/Software/web-server/tests/test_api_endpoints.py b/Software/web-server/tests/test_api_endpoints.py index 64780b0d..33551b17 100644 --- a/Software/web-server/tests/test_api_endpoints.py +++ b/Software/web-server/tests/test_api_endpoints.py @@ -163,3 +163,10 @@ def test_cors_headers(self, client): """Test CORS headers are present if needed""" response = client.get("/api/shot") assert response.status_code == 200 + + def test_internal_config_returns_merged_config(self, client, server_instance): + resp = client.get("/api/internal/config") + assert resp.status_code == 200 + body = resp.json() + assert body == server_instance.config_manager.build_generated_config() + assert "gs_config" in body From a412b6975911e9eddeccf011b29a5eb8d2e96bc0 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 19 Jun 2026 18:36:19 -0400 Subject: [PATCH 024/156] single source of truth for the web server port --- Software/web-server/constants.py | 1 + Software/web-server/main.py | 3 ++- Software/web-server/tests/test_smoke.py | 8 ++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Software/web-server/constants.py b/Software/web-server/constants.py index e7f3eb0d..0a539962 100644 --- a/Software/web-server/constants.py +++ b/Software/web-server/constants.py @@ -9,3 +9,4 @@ PITRAC_DIR = HOME_DIR / ".pitrac" IMAGES_DIR = Path(os.environ.get("PITRAC_IMAGES_DIR", str(HOME_DIR / "LM_Shares" / "Images"))) DB_PATH = Path(os.environ.get("PITRAC_DB_PATH", str(PITRAC_DIR / "pitrac.db"))) +SERVER_PORT = int(os.environ.get("PITRAC_WEB_PORT", "8080")) diff --git a/Software/web-server/main.py b/Software/web-server/main.py index 979238cd..62223d66 100644 --- a/Software/web-server/main.py +++ b/Software/web-server/main.py @@ -20,6 +20,7 @@ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) +from constants import SERVER_PORT # noqa: E402 from server import app # noqa: E402 if __name__ == "__main__": @@ -29,4 +30,4 @@ elif uvicorn_level == "critical": uvicorn_level = "error" - uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True, log_level=uvicorn_level) + uvicorn.run("main:app", host="0.0.0.0", port=SERVER_PORT, reload=True, log_level=uvicorn_level) diff --git a/Software/web-server/tests/test_smoke.py b/Software/web-server/tests/test_smoke.py index cc8a71b1..c8d80282 100644 --- a/Software/web-server/tests/test_smoke.py +++ b/Software/web-server/tests/test_smoke.py @@ -209,3 +209,11 @@ def test_server_instance_created(self, server_instance): assert server_instance.app is not None assert server_instance.shot_store is not None assert server_instance.connection_manager is not None + + +def test_server_port_defaults_to_8080(monkeypatch): + monkeypatch.delenv("PITRAC_WEB_PORT", raising=False) + import importlib + import constants + importlib.reload(constants) + assert constants.SERVER_PORT == 8080 From 91b4549cb95b6b26777293eac771f3de9afe0553 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 19 Jun 2026 19:26:29 -0400 Subject: [PATCH 025/156] stop generating the config file, pass the web server port to the binary --- Software/web-server/calibration_manager.py | 11 ++--- Software/web-server/pitrac_manager.py | 18 ++----- Software/web-server/server.py | 7 --- .../web-server/tests/test_pitrac_manager.py | 49 +++++++------------ 4 files changed, 26 insertions(+), 59 deletions(-) diff --git a/Software/web-server/calibration_manager.py b/Software/web-server/calibration_manager.py index 9839bc26..be8f53a3 100644 --- a/Software/web-server/calibration_manager.py +++ b/Software/web-server/calibration_manager.py @@ -21,6 +21,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional +from constants import SERVER_PORT + logger = logging.getLogger(__name__) CAMERA1_CALIBRATION_TIMEOUT = 40.0 # Camera1 has faster hardware detection @@ -460,9 +462,6 @@ async def run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any]: """ - generated_config_path = self.config_manager.generate_golf_sim_config() - logger.info(f"Generated config file at: {generated_config_path}") - return await self._run_auto_calibration(camera) async def _run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any]: @@ -809,7 +808,7 @@ def _build_cli_args_from_metadata(self, camera: str = "camera1") -> list: "--output_filename", "--show_images", "--config_file", - } # We handle config_file specially below + } # config_file is handled via the web server port instead for param in cli_params: key = param["key"] @@ -843,8 +842,8 @@ def _build_cli_args_from_metadata(self, camera: str = "camera1") -> list: # Use --key=value format for consistency args.append(f"{cli_arg}={value}") - # Always add the generated config file path - args.append(f"--config_file={self.config_manager.generated_config_path}") + # The binary fetches config over HTTP from the web server + args.append(f"--web_server_port={SERVER_PORT}") return args diff --git a/Software/web-server/pitrac_manager.py b/Software/web-server/pitrac_manager.py index 197992b9..cd5841a5 100644 --- a/Software/web-server/pitrac_manager.py +++ b/Software/web-server/pitrac_manager.py @@ -12,6 +12,7 @@ from typing import Optional, Dict, Any from config_manager import ConfigurationManager from log_files import run_log_path, latest_run_log, prune_run_logs, tail_lines +from constants import SERVER_PORT logger = logging.getLogger(__name__) @@ -87,14 +88,10 @@ def _build_cli_args_from_metadata(self) -> list: args.append(f"{cli_arg}={value}") return args - def _build_command(self, config_file_path: Optional[Path] = None) -> list: + def _build_command(self) -> list: cmd = [self.pitrac_binary] cmd.append("--system_mode=camera1") - - if config_file_path and Path(config_file_path).exists(): - cmd.append(f"--config_file={config_file_path}") - else: - logger.error("No config file path provided!") + cmd.append(f"--web_server_port={SERVER_PORT}") cmd.extend(self._build_cli_args_from_metadata()) @@ -120,13 +117,6 @@ async def start(self) -> Dict[str, Any]: Path(self.log_file).parent.mkdir(parents=True, exist_ok=True) Path(self.pid_file).parent.mkdir(parents=True, exist_ok=True) - try: - generated_config_path = self.config_manager.generate_golf_sim_config() - logger.info(f"Generated config file at: {generated_config_path}") - except RuntimeError as e: - logger.error(f"Failed to generate config: {e}") - return {"status": "error", "message": f"Failed to generate configuration: {e}", "error": str(e)} - env = os.environ.copy() home_dir = str(Path.home()) env["LD_LIBRARY_PATH"] = "/usr/lib/pitrac" @@ -152,7 +142,7 @@ async def start(self) -> Dict[str, Any]: Path(env["PITRAC_BASE_IMAGE_LOGGING_DIR"]).mkdir(parents=True, exist_ok=True) Path(env["PITRAC_WEBSERVER_SHARE_DIR"]).mkdir(parents=True, exist_ok=True) - cmd = self._build_command(config_file_path=generated_config_path) + cmd = self._build_command() self.log_file = run_log_path(self.log_dir, datetime.now()) prune_run_logs(self.log_dir, self.log_dir_cap_bytes) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 9a02ed80..6af391eb 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -447,10 +447,6 @@ async def update_config(key: str, request: Request): success, message, requires_restart = self.config_manager.set_config(key, value) if success: - # Rebuild the generated config so the C++ process picks up changes - generated_config_path = self.config_manager.generate_golf_sim_config() - logger.info(f"Generated config file at: {generated_config_path}") - # Broadcast update to WebSocket clients await self.connection_manager.broadcast( { @@ -479,7 +475,6 @@ async def reset_config(): success, message = self.config_manager.reset_all() if success: - self.config_manager.generate_golf_sim_config() await self.connection_manager.broadcast({"type": "config_reset"}) return {"success": True, "message": message} @@ -490,7 +485,6 @@ async def reload_config(): """Reload configuration from disk""" try: self.config_manager.reload() - self.config_manager.generate_golf_sim_config() return {"status": "Configuration reloaded"} except Exception as e: logger.error(f"Failed to reload config: {e}") @@ -509,7 +503,6 @@ async def import_config(request: Request): success, message = self.config_manager.import_config(config_data) if success: - self.config_manager.generate_golf_sim_config() await self.connection_manager.broadcast({"type": "config_import"}) return {"success": True, "message": message} diff --git a/Software/web-server/tests/test_pitrac_manager.py b/Software/web-server/tests/test_pitrac_manager.py index 55b721a2..59a057fc 100644 --- a/Software/web-server/tests/test_pitrac_manager.py +++ b/Software/web-server/tests/test_pitrac_manager.py @@ -8,6 +8,7 @@ from unittest.mock import Mock, patch, AsyncMock, mock_open from pitrac_manager import PiTracProcessManager +from constants import SERVER_PORT class TestPiTracProcessManager: @@ -176,12 +177,7 @@ async def test_start_success(self, manager): with patch("pitrac_manager.Path.mkdir"): with patch("pitrac_manager.Path.unlink"): with patch.object(manager, "is_running", return_value=False): - with patch.object( - manager.config_manager, - "generate_golf_sim_config", - return_value="/tmp/test_config.json", - ): - result = await manager.start() + result = await manager.start() assert result.get("status") in ["started", "failed"] if result["status"] == "started": @@ -209,14 +205,17 @@ async def test_start_with_exception(self, manager): """Test start with exception during process creation""" with patch("subprocess.Popen", side_effect=Exception("Test error")): with patch.object(manager, "is_running", return_value=False): - with patch.object( - manager.config_manager, "generate_golf_sim_config", return_value="/tmp/test_config.json" - ): - result = await manager.start() + result = await manager.start() assert result["status"] == "error" assert "Test error" in result["message"] + def test_command_has_web_server_port_not_config_file(self, manager): + """The binary fetches config over HTTP, so no --config_file is passed.""" + cmd = manager._build_command() + assert not any(a.startswith("--config_file") for a in cmd) + assert f"--web_server_port={SERVER_PORT}" in cmd + @pytest.mark.asyncio @@ -283,13 +282,8 @@ def mock_kill(pid, sig): with patch("pitrac_manager.Path.exists", return_value=True): with patch("pitrac_manager.Path.mkdir"): with patch("pitrac_manager.Path.unlink"): - with patch.object( - manager.config_manager, - "generate_golf_sim_config", - return_value="/tmp/test_config.json", - ): - with patch("asyncio.sleep", new_callable=AsyncMock): - result = await manager.restart() + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await manager.restart() assert result.get("status") in ["started", "restarted", "failed"] if result["status"] in ["started", "restarted"]: @@ -311,12 +305,7 @@ async def test_restart_not_running(self, manager): with patch("pitrac_manager.Path.mkdir"): with patch("pitrac_manager.Path.unlink"): with patch.object(manager, "is_running", return_value=False): - with patch.object( - manager.config_manager, - "generate_golf_sim_config", - return_value="/tmp/test_config.json", - ): - result = await manager.restart() + result = await manager.restart() assert result.get("status") in ["started", "restarted", "failed"] if result["status"] in ["started", "restarted"]: @@ -340,11 +329,8 @@ async def test_restart_with_delay(self, manager): with patch("pitrac_manager.Path.unlink"): with patch("pitrac_manager.Path.exists", return_value=True): with patch("pitrac_manager.Path.mkdir"): - with patch.object( - manager.config_manager, "generate_golf_sim_config", return_value="/tmp/test_config.json" - ): - with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - result = await manager.restart() + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + result = await manager.restart() mock_sleep.assert_called() assert result.get("status") in ["started", "restarted", "failed"] @@ -388,10 +374,9 @@ def manager(self): async def test_start_stop_cycle(self, manager): """Test complete start/stop cycle with subprocess""" - with patch.object(manager.config_manager, "generate_golf_sim_config", return_value="/tmp/test_config.json"): - with patch("pitrac_manager.Path.exists", return_value=True): - with patch("pathlib.Path.mkdir"): - result = await manager.start() + with patch("pitrac_manager.Path.exists", return_value=True): + with patch("pathlib.Path.mkdir"): + result = await manager.start() assert result["status"] in ["started", "failed"] From a526d9ed9fe34f92bd52c892d72756016ada7f60 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 20 Jun 2026 19:32:05 -0400 Subject: [PATCH 026/156] teach the binary the web server port and add a config fetch --- .../LMSourceCode/ImageProcessing/gs_fsm.cpp | 2 +- .../ImageProcessing/gs_http_client.cpp | 20 +++++++++++++++++++ .../ImageProcessing/gs_http_client.h | 1 + .../ImageProcessing/gs_options.cpp | 1 + .../LMSourceCode/ImageProcessing/gs_options.h | 3 +++ .../LMSourceCode/ImageProcessing/lm_main.cpp | 5 ++++- 6 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index f77c245e..e7e63006 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -841,7 +841,7 @@ namespace golf_sim { GS_LOG_MSG(info, "Skipping camera initialization for test mode: " + std::to_string(mode)); } - GsHttpClient::Init(); + GsHttpClient::Init("localhost", GolfSimOptions::GetCommandLineOptions().web_server_port_); GsUISystem::SendIPCStatusMessage(GsIPCResultType::kInitializing); if (!PulseStrobe::InitGPIOSystem(default_signal_handler)) { diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp index a24e50e1..cb7c2815 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp @@ -19,6 +19,26 @@ void GsHttpClient::Init(const std::string& host, int port) { port_ = port; } +std::string GsHttpClient::FetchConfig() { + try { + httplib::Client cli(host_, port_); + cli.set_connection_timeout(2); + cli.set_read_timeout(5); + + auto res = cli.Get("/api/internal/config"); + + if (!res || res->status != 200) { + GS_LOG_MSG(error, "Config fetch failed (status " + + std::to_string(res ? res->status : 0) + ")"); + return ""; + } + return res->body; + } catch (const std::exception& e) { + GS_LOG_MSG(error, "Config fetch exception: " + std::string(e.what())); + return ""; + } +} + void GsHttpClient::PostResult(const std::string& json_body) { try { httplib::Client cli(host_, port_); diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.h b/Software/LMSourceCode/ImageProcessing/gs_http_client.h index c027a1e8..6aa08f27 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.h +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.h @@ -16,6 +16,7 @@ namespace golf_sim { class GsHttpClient { public: static void Init(const std::string& host = "localhost", int port = 8080); + static std::string FetchConfig(); // GET /api/internal/config; returns body or "" on failure static void PostResult(const std::string& json_body); static void PostImageReady(const std::string& filename); diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.cpp b/Software/LMSourceCode/ImageProcessing/gs_options.cpp index 005e9906..3eed0b7c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_options.cpp @@ -141,6 +141,7 @@ void GolfSimOptions::Print() const std::cout << " gspro_host_address: " << gspro_host_address_ << std::endl; if (!config_file_.empty()) std::cout << " configuration file: " << config_file_ << std::endl; + std::cout << " web_server_port: " << std::to_string(web_server_port_) << std::endl; std::cout << " pulse_test: " << std::to_string(perform_pulse_test_) << std::endl; std::cout << " golfer_orientation: " << golfer_orientation_string_ << std::endl; std::cout << " practice_ball: " << std::to_string(practice_ball_) << std::endl; diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.h b/Software/LMSourceCode/ImageProcessing/gs_options.h index c08658cf..900773c9 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.h +++ b/Software/LMSourceCode/ImageProcessing/gs_options.h @@ -122,6 +122,8 @@ namespace golf_sim { "Specify the name or IP address of the host PC that is running the GSPro simulator. Default is: , indicating no GSPro sim is connected.") ("config_file", value(&config_file_)->default_value("golf_sim_config.json"), "Specify the filename with the JSON configuration. Default is: golf_sim_config.json") + ("web_server_port", value(&web_server_port_)->default_value(8080), + "Port of the PiTrac web server (on localhost) to fetch configuration from and post results to.") ("cmd_file,cmd", value(&command_line_file_)->implicit_value("config.txt"), "Read the options from a file. If no filename is specified, default to config.txt. " "In case of duplicate options, the ones provided on the command line will be used. " @@ -171,6 +173,7 @@ namespace golf_sim { unsigned int search_center_x_ = 0; unsigned int search_center_y_ = 0; double camera_gain_ = 0.0; // 1.0 might seem more appropriate, but we want to be able to see if this is set or not + int web_server_port_ = 8080; virtual bool Parse(int argc, char* argv[]); virtual void Print() const; diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 18f672fa..df60e924 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1174,7 +1174,7 @@ void run_main(int argc, char* argv[]) GS_LOG_MSG(info, "Running in kCamera1Calibrate or kCamera2Calibrate mode."); // We will want to send a calibration message to any monitor UIs - GsHttpClient::Init(); + GsHttpClient::Init("localhost", GolfSimOptions::GetCommandLineOptions().web_server_port_); GolfBall ball; cv::Mat img; @@ -1494,6 +1494,9 @@ int main(int argc, char *argv[]) GS_LOG_MSG(info, "Loading configuration from: " + config_file_name); + // Point the HTTP client at the web server before config load — Initialize fetches config from it. + GsHttpClient::Init("localhost", GolfSimOptions::GetCommandLineOptions().web_server_port_); + if (!GolfSimConfiguration::Initialize(config_file_name)) { GS_LOG_MSG(error, "Could not initialize configuration module using config file: " + config_file_name + ". Exiting."); return 0; From 432d19494f99b17d9b9b966d77696471cf87ba06 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 21 Jun 2026 18:19:41 -0400 Subject: [PATCH 027/156] fetch config over http at startup instead of reading the file --- .../ImageProcessing/configuration_manager.cpp | 29 +++++++++++++----- .../ImageProcessing/configuration_manager.h | 4 ++- .../ImageProcessing/gs_config.cpp | 30 +++++++++++++++++-- .../LMSourceCode/ImageProcessing/gs_options.h | 4 +-- .../LMSourceCode/ImageProcessing/lm_main.cpp | 11 ++++--- 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp b/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp index 463b5e14..13b1b0f4 100644 --- a/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp +++ b/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp @@ -7,6 +7,7 @@ #include "logging_tools.h" #include #include +#include #include #include @@ -63,14 +64,26 @@ namespace golf_sim { bool ConfigurationManager::Initialize( const std::string& json_config_file, const std::string& yaml_config_file, - const std::map& cli_overrides) { - + const std::map& cli_overrides, + const std::string& json_config_body) { + GS_LOG_TRACE_MSG(trace, "Initializing ConfigurationManager"); - + json_config_file_ = json_config_file; - - // Load system defaults from golf_sim_config.json - if (std::filesystem::exists(json_config_file)) { + json_config_body_ = json_config_body; + + // Load system defaults: from the supplied body (fetched over HTTP — single read) when given, + // otherwise from the json_config_file on disk. + if (!json_config_body.empty()) { + try { + std::istringstream body_stream(json_config_body); + boost::property_tree::read_json(body_stream, json_config_); + GS_LOG_MSG(info, "Loaded system defaults from web server config body"); + } catch (const boost::property_tree::json_parser_error& e) { + GS_LOG_MSG(error, "Failed to parse system config body: " + std::string(e.what())); + return false; + } + } else if (std::filesystem::exists(json_config_file)) { try { boost::property_tree::read_json(json_config_file, json_config_); GS_LOG_MSG(info, "Loaded system defaults from: " + json_config_file); @@ -309,8 +322,8 @@ bool ConfigurationManager::Reload() { yaml_config_.clear(); // Keep CLI overrides - // Reload files - return Initialize(json_config_file_, yaml_config_file_, {}); + // Reload — re-parse the cached HTTP body if we have one, else re-read the file + return Initialize(json_config_file_, yaml_config_file_, {}, json_config_body_); } bool ConfigurationManager::LoadMappings(const std::string& mappings_file) { diff --git a/Software/LMSourceCode/ImageProcessing/configuration_manager.h b/Software/LMSourceCode/ImageProcessing/configuration_manager.h index 1f327b11..1e463c17 100644 --- a/Software/LMSourceCode/ImageProcessing/configuration_manager.h +++ b/Software/LMSourceCode/ImageProcessing/configuration_manager.h @@ -48,7 +48,8 @@ class ConfigurationManager { bool Initialize( const std::string& json_config_file = ".pitrac/config/generated_golf_sim_config.json", const std::string& yaml_config_file = "", - const std::map& cli_overrides = {} + const std::map& cli_overrides = {}, + const std::string& json_config_body = "" // when non-empty, parse this instead of reading json_config_file ); /** @@ -167,6 +168,7 @@ class ConfigurationManager { // File paths for reloading std::string json_config_file_; std::string yaml_config_file_; + std::string json_config_body_; // cached config body (from HTTP) so Reload re-parses it instead of re-reading a file // Thread safety mutable std::mutex config_mutex_; diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index eebc626f..424d77d8 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include "gs_http_client.h" #include "logging_tools.h" #include "gs_camera.h" #include "gs_ui_system.h" @@ -32,8 +34,30 @@ namespace golf_sim { bool GolfSimConfiguration::Initialize(const std::string& configuration_filename) { + // Obtain the config body ONCE: read the file when a path is explicitly given + // (e.g. the testing tools that inject runtime keys), otherwise fetch it from the + // web server over HTTP. Both ConfigurationManager and configuration_root_ parse + // this same body — a single read, no second file/HTTP fetch. + std::string config_body; + if (!configuration_filename.empty() && std::filesystem::exists(configuration_filename)) { + std::ifstream f(configuration_filename); + std::stringstream ss; + ss << f.rdbuf(); + config_body = ss.str(); + } else { +#ifdef __unix__ + config_body = GsHttpClient::FetchConfig(); +#endif + } + + if (config_body.empty()) { + GS_LOG_MSG(error, "GolfSimConfiguration::Initialize failed: no config. The web server must be running to serve /api/internal/config, or pass --config_file= to read from a file."); + return false; + } + try { - boost::property_tree::read_json(configuration_filename, configuration_root_); + std::istringstream config_stream(config_body); + boost::property_tree::read_json(config_stream, configuration_root_); } catch (std::exception const& e) { @@ -41,9 +65,9 @@ namespace golf_sim { return false; } - // Initialize new ConfigurationManager for override support + // Initialize ConfigurationManager (override support) from the SAME body — single read. ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (!config_mgr.Initialize(configuration_filename)) { + if (!config_mgr.Initialize(configuration_filename, "", {}, config_body)) { GS_LOG_MSG(warning, "ConfigurationManager initialization failed, using JSON only"); } else { GS_LOG_MSG(info, "ConfigurationManager initialized with override support"); diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.h b/Software/LMSourceCode/ImageProcessing/gs_options.h index 900773c9..76ed6f65 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.h +++ b/Software/LMSourceCode/ImageProcessing/gs_options.h @@ -120,8 +120,8 @@ namespace golf_sim { "Specify the name or IP address of the host PC that is running the E6 simulator. Default is: , indicating no TruGolf sim is connected.") ("gspro_host_address", value(&gspro_host_address_)->default_value(""), "Specify the name or IP address of the host PC that is running the GSPro simulator. Default is: , indicating no GSPro sim is connected.") - ("config_file", value(&config_file_)->default_value("golf_sim_config.json"), - "Specify the filename with the JSON configuration. Default is: golf_sim_config.json") + ("config_file", value(&config_file_)->default_value(""), + "Read configuration from this JSON file instead of fetching it from the web server. Default is empty (fetch over HTTP).") ("web_server_port", value(&web_server_port_)->default_value(8080), "Port of the PiTrac web server (on localhost) to fetch configuration from and post results to.") ("cmd_file,cmd", value(&command_line_file_)->implicit_value("config.txt"), diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index df60e924..03fb6421 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1486,13 +1486,12 @@ int main(int argc, char *argv[]) GolfSimOptions::GetCommandLineOptions().Print(); - std::string config_file_name = ".pitrac/config/generated_golf_sim_config.json"; + // Empty unless --config_file was explicitly given; empty => fetch config over HTTP. + std::string config_file_name = GolfSimOptions::GetCommandLineOptions().config_file_; - if (!GolfSimOptions::GetCommandLineOptions().config_file_.empty()) { - config_file_name = GolfSimOptions::GetCommandLineOptions().config_file_; - } - - GS_LOG_MSG(info, "Loading configuration from: " + config_file_name); + GS_LOG_MSG(info, config_file_name.empty() + ? "Loading configuration from the web server over HTTP" + : "Loading configuration from file: " + config_file_name); // Point the HTTP client at the web server before config load — Initialize fetches config from it. GsHttpClient::Init("localhost", GolfSimOptions::GetCommandLineOptions().web_server_port_); From 82401d36182749c7d626c95fe84a9f96ef159565 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 22 Jun 2026 20:33:19 -0400 Subject: [PATCH 028/156] serve every setting in the merged config, not just the json ones --- Software/web-server/config_manager.py | 6 +----- Software/web-server/tests/test_config_manager.py | 7 +++++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index 61d2b1c3..e2d17a89 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -576,7 +576,7 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: def build_generated_config(self) -> Dict[str, Any]: """Build the merged config dict served to pitrac_lm (defaults + calibration + user settings, - excluding cli/env-passed settings).""" + all settings regardless of passedVia).""" config = {} metadata = self.load_configurations_metadata() settings_metadata = metadata.get("settings", {}) @@ -586,10 +586,6 @@ def build_generated_config(self) -> Dict[str, Any]: json_settings_count = 0 for key, setting_info in settings_metadata.items(): - passed_via = setting_info.get("passedVia", "json") - if passed_via in ["cli", "environment"]: - continue - value = self.get_config(key) if value is not None: self._set_nested_json(config, key, value) diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index 91b9c136..8f3c8895 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -674,3 +674,10 @@ def test_generate_golf_sim_config_matches_builder(self, config_manager): import json path = config_manager.generate_golf_sim_config() assert json.loads(path.read_text()) == config_manager.build_generated_config() + + def test_build_generated_config_includes_former_cli_and_env(self, config_manager): + cfg = config_manager.build_generated_config() + # former CLI setting — gs_config.player.kGolferOrientation + assert "kGolferOrientation" in cfg.get("gs_config", {}).get("player", {}) + # former environment setting — cameras.slot1.type + assert "type" in cfg.get("cameras", {}).get("slot1", {}) From c5746318f41482e464852d48a632a6a561017686 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 22 Jun 2026 21:25:58 -0400 Subject: [PATCH 029/156] stop passing settings as cli args and env vars --- Software/web-server/calibration_manager.py | 177 +----------------- Software/web-server/config_manager.py | 32 ---- Software/web-server/pitrac_manager.py | 47 ----- Software/web-server/testing_tools_manager.py | 14 -- .../web-server/tests/test_config_manager.py | 19 -- .../tests/test_testing_tools_manager.py | 3 - .../web-server/tests/utils/mock_factories.py | 3 - 7 files changed, 10 insertions(+), 285 deletions(-) diff --git a/Software/web-server/calibration_manager.py b/Software/web-server/calibration_manager.py index be8f53a3..3183a79c 100644 --- a/Software/web-server/calibration_manager.py +++ b/Software/web-server/calibration_manager.py @@ -395,38 +395,8 @@ async def check_ball_location(self, camera: str = "camera1") -> Dict[str, Any]: "last_run": datetime.now().isoformat(), } - config = self.config_manager.get_config() - cmd = [self.pitrac_binary, f"--system_mode={camera}_ball_location"] - - if camera == "camera1": - search_x = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterX") - if search_x is None: - search_x = 850 # Default from configurations.json - search_y = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterY") - if search_y is None: - search_y = 500 # Default from configurations.json - else: - search_x = 700 - search_y = 500 - - logging_level = config.get("logging", {}).get("level", "warn") - - camera_gain_key = "kCamera1Gain" if camera == "camera1" else "kCamera2Gain" - camera_gain = self.config_manager.get_config(f"gs_config.cameras.{camera_gain_key}") - if camera_gain is None: - camera_gain = 6.0 - - cmd.extend( - [ - f"--search_center_x={search_x}", - f"--search_center_y={search_y}", - f"--logging_level={logging_level}", - "--artifact_save_level=all", - f"--camera_gain={camera_gain}", - ] - ) - cmd.extend(self._build_cli_args_from_metadata(camera)) + cmd.extend(self._build_web_server_args()) try: result = await self._run_calibration_command(cmd, camera, timeout=30) @@ -484,34 +454,8 @@ async def _run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any] self._active_calibrations[session_id] = session_data logger.info(f"Pre-registered calibration session {session_id} for {camera}") - config = self.config_manager.get_config() cmd = [self.pitrac_binary, f"--system_mode={camera}AutoCalibrate"] - - search_x = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterX") - if search_x is None: - search_x = 850 # Default from configurations.json - - search_y = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterY") - if search_y is None: - search_y = 500 # Default from configurations.json - - logging_level = config.get("logging", {}).get("level", "warn") - - camera_gain = self.config_manager.get_config("gs_config.cameras.kCamera1Gain") - if camera_gain is None: - camera_gain = 6.0 - - cmd.extend( - [ - f"--search_center_x={search_x}", - f"--search_center_y={search_y}", - f"--logging_level={logging_level}", - "--artifact_save_level=all", - "--show_images=0", - f"--camera_gain={camera_gain}", - ] - ) - cmd.extend(self._build_cli_args_from_metadata(camera)) + cmd.extend(self._build_web_server_args()) log_file = self.log_dir / f"calibration_{camera}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" @@ -659,37 +603,8 @@ async def run_manual_calibration(self, camera: str = "camera1") -> Dict[str, Any "last_run": datetime.now().isoformat(), } - config = self.config_manager.get_config() cmd = [self.pitrac_binary, f"--system_mode={camera}Calibrate"] - - if camera == "camera1": - search_x = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterX") - if search_x is None: - search_x = 850 # Default from configurations.json - search_y = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterY") - if search_y is None: - search_y = 500 # Default from configurations.json - else: - search_x = 700 - search_y = 500 - - logging_level = config.get("logging", {}).get("level", "warn") - - camera_gain_key = "kCamera1Gain" if camera == "camera1" else "kCamera2Gain" - camera_gain = self.config_manager.get_config(f"gs_config.cameras.{camera_gain_key}") - if camera_gain is None: - camera_gain = 6.0 - - cmd.extend( - [ - f"--search_center_x={search_x}", - f"--search_center_y={search_y}", - f"--logging_level={logging_level}", - "--artifact_save_level=all", - f"--camera_gain={camera_gain}", - ] - ) - cmd.extend(self._build_cli_args_from_metadata(camera)) + cmd.extend(self._build_web_server_args()) try: result = await self._run_calibration_command(cmd, camera, timeout=180) @@ -734,7 +649,6 @@ async def capture_still_image(self, camera: str = "camera1") -> Dict[str, Any]: """ logger.info(f"Capturing still image for {camera}") - config = self.config_manager.get_config() cmd = [self.pitrac_binary, f"--system_mode={camera}", "--cam_still_mode"] timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -743,8 +657,8 @@ async def capture_still_image(self, camera: str = "camera1") -> Dict[str, Any]: images_dir.mkdir(parents=True, exist_ok=True) output_path = images_dir / output_file - cmd.extend([f"--output_filename={output_path}", "--artifact_save_level=final_results_only"]) - cmd.extend(self._build_cli_args_from_metadata(camera)) + cmd.append(f"--output_filename={output_path}") + cmd.extend(self._build_web_server_args()) try: await self._run_calibration_command(cmd, camera, timeout=10) @@ -786,66 +700,13 @@ def get_calibration_data(self) -> Dict[str, Any]: }, } - def _build_cli_args_from_metadata(self, camera: str = "camera1") -> list: - """Build CLI arguments using metadata from configurations.json + def _build_web_server_args(self) -> list: + """Args telling the binary where to fetch its merged config over HTTP. - This method uses the passedVia metadata to automatically - build CLI arguments, similar to pitrac_manager.py + Everything else (search center, gain, logging level, etc.) now lives + in the served config tree, so we no longer pass it on the command line. """ - args = [] - merged_config = self.config_manager.get_config() - - cli_params = self.config_manager.get_cli_parameters() - - # Skip args that we handle separately or need special handling - skip_args = { - "--system_mode", - "--search_center_x", - "--search_center_y", - "--logging_level", - "--artifact_save_level", - "--cam_still_mode", - "--output_filename", - "--show_images", - "--config_file", - } # config_file is handled via the web server port instead - - for param in cli_params: - key = param["key"] - cli_arg = param["cliArgument"] - param_type = param["type"] - - if cli_arg in skip_args: - continue - - value = merged_config - for part in key.split("."): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - - if value is None: - continue - - # Skip empty string values for non-boolean parameters - if param_type != "boolean" and value == "": - continue - - if param_type == "boolean": - if value: - args.append(cli_arg) - else: - if param_type == "path" and value: - value = str(value).replace("~", str(Path.home())) - # Use --key=value format for consistency - args.append(f"{cli_arg}={value}") - - # The binary fetches config over HTTP from the web server - args.append(f"--web_server_port={SERVER_PORT}") - - return args + return [f"--web_server_port={SERVER_PORT}"] def _build_environment(self, camera: str = "camera1") -> dict: """Build environment variables from config @@ -866,24 +727,6 @@ def _build_environment(self, camera: str = "camera1") -> dict: env.setdefault("LIBPISP_LOG_LEVEL", "4") env.setdefault("LIBCAMERA_LOG_LEVELS", "*:ERROR") - # Camera types come from cameras.slot1.type and cameras.slot2.type (default 5 = InnoMaker IMX296) - slot1_type = config.get("cameras", {}).get("slot1", {}).get("type", 5) - slot2_type = config.get("cameras", {}).get("slot2", {}).get("type", 5) - env["PITRAC_SLOT1_CAMERA_TYPE"] = str(slot1_type) - env["PITRAC_SLOT2_CAMERA_TYPE"] = str(slot2_type) - - # Lens types come from cameras.slot1.lens and cameras.slot2.lens (default 1 = 6mm) - slot1_lens = config.get("cameras", {}).get("slot1", {}).get("lens", 1) - slot2_lens = config.get("cameras", {}).get("slot2", {}).get("lens", 1) - env["PITRAC_SLOT1_LENS_TYPE"] = str(slot1_lens) - env["PITRAC_SLOT2_LENS_TYPE"] = str(slot2_lens) - - # Orientation types come from cameras.slot1.orientation and cameras.slot2.orientation (default 1 = UpsideUp) - slot1_orientation = config.get("cameras", {}).get("slot1", {}).get("orientation", 1) - slot2_orientation = config.get("cameras", {}).get("slot2", {}).get("orientation", 1) - env["PITRAC_SLOT1_CAMERA_ORIENTATION"] = str(slot1_orientation) - env["PITRAC_SLOT2_CAMERA_ORIENTATION"] = str(slot2_orientation) - base_dir = config.get("gs_config", {}).get("logging", {}).get("kPCBaseImageLoggingDir", "~/LM_Shares/Images/") env["PITRAC_BASE_IMAGE_LOGGING_DIR"] = str(base_dir).replace("~", str(Path.home())) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index e2d17a89..4cea57ca 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -718,38 +718,6 @@ def load_configurations_metadata(self): logger.error(f"Error loading configurations.json: {e}") return {"settings": {}} - def get_cli_parameters(self) -> List[Dict[str, Any]]: - """Get all CLI parameters to pass to the pitrac_lm process.""" - metadata = self.load_configurations_metadata() - settings = metadata.get("settings", {}) - - cli_params = [] - for key, info in settings.items(): - if info.get("passedVia") == "cli": - cli_params.append({ - "key": key, - "cliArgument": info.get("cliArgument"), - "type": info.get("type"), - "default": info.get("default"), - }) - return cli_params - - def get_environment_parameters(self) -> List[Dict[str, Any]]: - """Get all environment parameters to set for the pitrac_lm process.""" - metadata = self.load_configurations_metadata() - settings = metadata.get("settings", {}) - - env_params = [] - for key, info in settings.items(): - if info.get("passedVia") == "environment": - env_params.append({ - "key": key, - "envVariable": info.get("envVariable"), - "type": info.get("type"), - "default": info.get("default"), - }) - return env_params - def flatten_config(self, config: Dict[str, Any], prefix: str = "") -> Dict[str, Any]: """Flatten nested config dict into dot-notation keys.""" result = {} diff --git a/Software/web-server/pitrac_manager.py b/Software/web-server/pitrac_manager.py index cd5841a5..bca75650 100644 --- a/Software/web-server/pitrac_manager.py +++ b/Software/web-server/pitrac_manager.py @@ -57,44 +57,11 @@ def expand_path(path_str: str) -> Path: self.log_file.parent.mkdir(parents=True, exist_ok=True) self.pid_file.parent.mkdir(parents=True, exist_ok=True) - def _build_cli_args_from_metadata(self) -> list: - args = [] - merged_config = self.config_manager.get_config() - skip_args = {"--system_mode", "--web_server_share_dir"} - - for param in self.config_manager.get_cli_parameters(): - cli_arg = param["cliArgument"] - param_type = param["type"] - if cli_arg in skip_args: - continue - key = param["key"] - value = merged_config - for part in key.split("."): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - if value is None: - continue - if param_type != "boolean" and value == "": - continue - if param_type == "boolean": - if value: - args.append(cli_arg) - else: - if param_type == "path" and value: - value = str(value).replace("~", str(Path.home())) - args.append(f"{cli_arg}={value}") - return args - def _build_command(self) -> list: cmd = [self.pitrac_binary] cmd.append("--system_mode=camera1") cmd.append(f"--web_server_port={SERVER_PORT}") - cmd.extend(self._build_cli_args_from_metadata()) - config = self.config_manager.get_config() web_share_dir = ( config.get("gs_config", {}).get("ipc_interface", {}).get("kWebServerShareDirectory", "~/LM_Shares/Images/") @@ -125,20 +92,6 @@ async def start(self) -> Dict[str, Any]: env["PITRAC_BASE_IMAGE_LOGGING_DIR"] = "~/LM_Shares/Images/".replace("~", home_dir) env["PITRAC_WEBSERVER_SHARE_DIR"] = "~/LM_Shares/WebShare/".replace("~", home_dir) - merged_config = self.config_manager.get_config() - for param in self.config_manager.get_environment_parameters(): - key = param["key"] - env_var = param["envVariable"] - value = merged_config - for part in key.split("."): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - if value is not None and value != "": - env[env_var] = str(value) - Path(env["PITRAC_BASE_IMAGE_LOGGING_DIR"]).mkdir(parents=True, exist_ok=True) Path(env["PITRAC_WEBSERVER_SHARE_DIR"]).mkdir(parents=True, exist_ok=True) diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index bf337bc6..8817851c 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -232,20 +232,6 @@ async def run_tool(self, tool_id: str) -> Dict[str, Any]: env["DISPLAY"] = ":0.0" env["OMP_WAIT_POLICY"] = "PASSIVE" - merged_config = self.config_manager.get_config() - for param in self.config_manager.get_environment_parameters(): - key = param["key"] - env_var = param["envVariable"] - value = merged_config - for part in key.split("."): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - if value is not None and value != "": - env[env_var] = str(value) - if tool_info["requires_sudo"]: cmd = ["sudo", "-E"] + cmd diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index 8f3c8895..da443455 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -149,25 +149,6 @@ def test_missing_file_handling(self, config_manager): assert config_manager.calibration_data == {} assert isinstance(config_manager.merged_config, dict) - def test_cli_parameters(self, config_manager): - """Test getting CLI parameters from config""" - config_manager.system_config = { - "gs_config": {"cameras": {"kCamera1Gain": "2.0"}, "modes": {"kStartInPuttingMode": "1"}} - } - - cli_params = config_manager.get_cli_parameters() - assert isinstance(cli_params, list) - - def test_environment_parameters(self, config_manager): - """Test getting environment parameters from config""" - config_manager.system_config = { - "gs_config": {"ipc_interface": {"kWebServerShareDirectory": "~/LM_Shares/WebShare"}} - } - config_manager.reload() - - env_params = config_manager.get_environment_parameters() - assert isinstance(env_params, list) - def test_nested_config_access(self, config_manager): """Test accessing deeply nested configuration values""" config_manager.user_settings = { diff --git a/Software/web-server/tests/test_testing_tools_manager.py b/Software/web-server/tests/test_testing_tools_manager.py index de1a0b03..bc708625 100644 --- a/Software/web-server/tests/test_testing_tools_manager.py +++ b/Software/web-server/tests/test_testing_tools_manager.py @@ -17,9 +17,6 @@ def mock_config_manager(): }, } manager.generate_golf_sim_config.return_value = "/tmp/test_config.json" - manager.get_environment_parameters.return_value = [ - {"key": "camera1.slot1_camera_type", "envVariable": "PITRAC_SLOT1_CAMERA_TYPE"} - ] return manager diff --git a/Software/web-server/tests/utils/mock_factories.py b/Software/web-server/tests/utils/mock_factories.py index 484e414e..f5946a5c 100644 --- a/Software/web-server/tests/utils/mock_factories.py +++ b/Software/web-server/tests/utils/mock_factories.py @@ -102,9 +102,6 @@ def create_pitrac_config_manager() -> Mock: "settings": {}, } - manager.get_cli_parameters.return_value = [] - manager.get_environment_parameters.return_value = [] - return manager From bf0476d1c673a39a503c80b3186300520e6a9a2b Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 23 Jun 2026 19:56:04 -0400 Subject: [PATCH 030/156] add OGS connection settings under the Simulators config category --- Software/web-server/configurations.json | 59 ++++++++++++++++++++++ Software/web-server/tests/test_sims_api.py | 7 +++ 2 files changed, 66 insertions(+) create mode 100644 Software/web-server/tests/test_sims_api.py diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index b3c01bf1..5cd3b3b0 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -751,6 +751,65 @@ "requiresRestart": true, "passedVia": "json" }, + "simulators.ogs.enabled": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "OpenGolfSim Enabled", + "description": "Show OpenGolfSim in the simulators drawer and allow connecting", + "type": "boolean", + "default": false, + "requiresRestart": false, + "passedVia": "python" + }, + "simulators.ogs.auto_connect": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "OpenGolfSim Auto-Connect", + "description": "Open the OpenGolfSim connection automatically when the server starts", + "type": "boolean", + "default": false, + "requiresRestart": false, + "passedVia": "python" + }, + "simulators.ogs.host": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "OpenGolfSim Host", + "description": "IP address of the device running OpenGolfSim (e.g. the iPad)", + "type": "ip_address", + "default": "", + "requiresRestart": false, + "passedVia": "python" + }, + "simulators.ogs.port": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "OpenGolfSim Port", + "description": "OpenGolfSim Developer API port", + "type": "number", + "min": 1, + "max": 65535, + "default": 3111, + "requiresRestart": false, + "passedVia": "python" + }, + "simulators.ogs.keepalive_sec": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "OpenGolfSim Keepalive (sec)", + "description": "How often to re-send the device-ready heartbeat so OpenGolfSim keeps the monitor connected", + "type": "number", + "min": 1, + "max": 60, + "default": 5, + "requiresRestart": false, + "passedVia": "python" + }, "gs_config.ball_identification.kUseCLAHEProcessing": { "category": "Ball Detection", "subcategory": "basic", diff --git a/Software/web-server/tests/test_sims_api.py b/Software/web-server/tests/test_sims_api.py new file mode 100644 index 00000000..0807056a --- /dev/null +++ b/Software/web-server/tests/test_sims_api.py @@ -0,0 +1,7 @@ +def test_ogs_config_defaults_resolve(server_instance): + cm = server_instance.config_manager + assert cm.get_config("simulators.ogs.enabled") is False + assert cm.get_config("simulators.ogs.auto_connect") is False + assert cm.get_config("simulators.ogs.host") == "" + assert cm.get_config("simulators.ogs.port") == 3111 + assert cm.get_config("simulators.ogs.keepalive_sec") == 5 From e5b5eebf6db311bdda8b31054d4268b58c22c488 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 25 Jun 2026 20:19:47 -0400 Subject: [PATCH 031/156] add SimInterface base for the python sim layer --- Software/web-server/sim_interface.py | 70 ++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 Software/web-server/sim_interface.py diff --git a/Software/web-server/sim_interface.py b/Software/web-server/sim_interface.py new file mode 100644 index 00000000..8b25b368 --- /dev/null +++ b/Software/web-server/sim_interface.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import Awaitable, Callable, Dict, Optional + +from models import ShotData + +logger = logging.getLogger(__name__) + +StatusCallback = Callable[[], Awaitable[None]] + +STATUS_OFF = "off" +STATUS_CONNECTING = "connecting" +STATUS_CONNECTED = "connected" +STATUS_ERROR = "error" + + +class SimInterface(ABC): + """Base for a single simulator output target. + + A sim holds its own connection and converts a ShotData into whatever its + simulator expects. SimManager owns the collection and the status broadcast. + """ + + name = "sim" + display_name = "Simulator" + + def __init__(self) -> None: + self._status = STATUS_OFF + self._detail = "" + self._status_callback: Optional[StatusCallback] = None + + def set_status_callback(self, callback: StatusCallback) -> None: + self._status_callback = callback + + @property + def status(self) -> str: + return self._status + + async def _set_status(self, status: str, detail: str = "") -> None: + if status == self._status and detail == self._detail: + return + self._status = status + self._detail = detail + if self._status_callback is not None: + try: + await self._status_callback() + except Exception as e: + logger.warning(f"sim status callback failed: {e}") + + def info(self) -> Dict[str, str]: + return { + "name": self.name, + "display_name": self.display_name, + "status": self._status, + "detail": self._detail, + } + + @abstractmethod + async def connect(self) -> None: + ... + + @abstractmethod + async def disconnect(self) -> None: + ... + + @abstractmethod + async def send_shot(self, shot: ShotData) -> None: + ... From 6a541887b1b0f655e0280628a480025cac51b48e Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 27 Jun 2026 19:32:17 -0400 Subject: [PATCH 032/156] add OGSSim with persistent connection, shot mapping, keepalive and reconnect --- Software/web-server/sims/__init__.py | 0 Software/web-server/sims/ogs_sim.py | 157 ++++++++++++++++++++++ Software/web-server/tests/test_ogs_sim.py | 83 ++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 Software/web-server/sims/__init__.py create mode 100644 Software/web-server/sims/ogs_sim.py create mode 100644 Software/web-server/tests/test_ogs_sim.py diff --git a/Software/web-server/sims/__init__.py b/Software/web-server/sims/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Software/web-server/sims/ogs_sim.py b/Software/web-server/sims/ogs_sim.py new file mode 100644 index 00000000..7f01b77f --- /dev/null +++ b/Software/web-server/sims/ogs_sim.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import math +from typing import Dict, Optional + +from models import ShotData +from sim_interface import ( + SimInterface, + STATUS_CONNECTED, + STATUS_CONNECTING, + STATUS_ERROR, + STATUS_OFF, +) + +logger = logging.getLogger(__name__) + +_RECONNECT_BACKOFF_SEC = [1, 2, 5, 10] + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def build_shot_payload(shot: ShotData) -> Dict[str, object]: + back = float(shot.back_spin) + side = float(shot.side_spin) + spin_speed = round(math.hypot(back, side)) + spin_axis = math.degrees(math.atan2(side, back)) if (back or side) else 0.0 + return { + "type": "shot", + "unit": "imperial", + "shot": { + "ballSpeed": round(float(shot.speed), 1), + "verticalLaunchAngle": round(_clamp(float(shot.launch_angle), 0, 45), 1), + "horizontalLaunchAngle": round(_clamp(float(shot.side_angle), -45, 45), 1), + "spinSpeed": spin_speed, + "spinAxis": round(_clamp(spin_axis, -45, 45), 1), + }, + } + + +class OGSSim(SimInterface): + name = "ogs" + display_name = "OpenGolfSim" + + def __init__(self, host: str, port: int = 3111, keepalive_sec: int = 5) -> None: + super().__init__() + self.host = host + self.port = port + self.keepalive_sec = max(1, int(keepalive_sec)) + self._writer: Optional[asyncio.StreamWriter] = None + self._reader: Optional[asyncio.StreamReader] = None + self._keepalive_task: Optional[asyncio.Task] = None + self._reconnect_task: Optional[asyncio.Task] = None + self._want_connected = False + + def info(self) -> Dict[str, str]: + data = super().info() + data["target"] = f"{self.host}:{self.port}" if self.host else "" + return data + + async def connect(self) -> None: + self._want_connected = True + await self._open() + + async def _open(self) -> None: + if not self.host: + await self._set_status(STATUS_ERROR, "no host configured") + return + await self._set_status(STATUS_CONNECTING, f"{self.host}:{self.port}") + try: + self._reader, self._writer = await asyncio.open_connection(self.host, self.port) + except Exception as e: + logger.warning(f"OGS connect failed: {e}") + await self._set_status(STATUS_ERROR, str(e)) + self._schedule_reconnect() + return + await self._send_obj({"type": "device", "status": "ready"}) + await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") + self._keepalive_task = asyncio.create_task(self._keepalive_loop()) + + async def _send_obj(self, obj: Dict[str, object]) -> None: + if self._writer is None: + raise ConnectionError("not connected") + self._writer.write((json.dumps(obj) + "\n").encode("utf-8")) + await self._writer.drain() + + async def send_shot(self, shot: ShotData) -> None: + if self._writer is None: + raise ConnectionError("OGS not connected") + try: + await self._send_obj(build_shot_payload(shot)) + except Exception as e: + logger.warning(f"OGS send_shot failed: {e}") + await self._set_status(STATUS_ERROR, str(e)) + self._schedule_reconnect() + raise + + async def _keepalive_loop(self) -> None: + try: + while self._want_connected and self._writer is not None: + await asyncio.sleep(self.keepalive_sec) + try: + await self._send_obj({"type": "device", "status": "ready"}) + except Exception as e: + logger.warning(f"OGS keepalive failed: {e}") + await self._set_status(STATUS_ERROR, str(e)) + self._schedule_reconnect() + return + except asyncio.CancelledError: + pass + + def _schedule_reconnect(self) -> None: + self._teardown_socket() + if not self._want_connected: + return + if self._reconnect_task and not self._reconnect_task.done(): + return + self._reconnect_task = asyncio.create_task(self._reconnect_loop()) + + async def _reconnect_loop(self) -> None: + attempt = 0 + while self._want_connected and self._writer is None: + delay = _RECONNECT_BACKOFF_SEC[min(attempt, len(_RECONNECT_BACKOFF_SEC) - 1)] + await asyncio.sleep(delay) + if not self._want_connected: + return + await self._open() + attempt += 1 + + def _teardown_socket(self) -> None: + if self._keepalive_task and not self._keepalive_task.done(): + self._keepalive_task.cancel() + self._keepalive_task = None + if self._writer is not None: + try: + self._writer.close() + except Exception: + pass + self._writer = None + self._reader = None + + async def disconnect(self) -> None: + self._want_connected = False + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + writer = self._writer + self._teardown_socket() + if writer is not None: + try: + await writer.wait_closed() + except Exception: + pass + await self._set_status(STATUS_OFF, "") diff --git a/Software/web-server/tests/test_ogs_sim.py b/Software/web-server/tests/test_ogs_sim.py new file mode 100644 index 00000000..c4919fb2 --- /dev/null +++ b/Software/web-server/tests/test_ogs_sim.py @@ -0,0 +1,83 @@ +import asyncio +import json + +import pytest + +from models import ShotData +from sims.ogs_sim import OGSSim, build_shot_payload + + +def test_build_shot_payload_maps_and_clamps(): + shot = ShotData(speed=101.2, launch_angle=15.4, side_angle=-2.1, + back_spin=3000, side_spin=-300) + p = build_shot_payload(shot) + assert p["type"] == "shot" + assert p["unit"] == "imperial" + s = p["shot"] + assert s["ballSpeed"] == 101.2 + assert s["verticalLaunchAngle"] == 15.4 + assert s["horizontalLaunchAngle"] == -2.1 + # total spin magnitude and a negative (left) axis for negative side spin + assert s["spinSpeed"] == pytest.approx(3015, abs=1) + assert s["spinAxis"] < 0 + + +def test_build_shot_payload_clamps_out_of_range(): + shot = ShotData(speed=200, launch_angle=80, side_angle=-90, + back_spin=0, side_spin=9000) + s = build_shot_payload(shot)["shot"] + assert s["verticalLaunchAngle"] == 45 + assert s["horizontalLaunchAngle"] == -45 + assert -45 <= s["spinAxis"] <= 45 + + +class _FakeOGS: + """Minimal asyncio TCP server that records newline-delimited JSON it receives.""" + def __init__(self): + self.messages = [] + self._server = None + self.port = None + + async def start(self): + self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0) + self.port = self._server.sockets[0].getsockname()[1] + + async def _handle(self, reader, writer): + while True: + line = await reader.readline() + if not line: + break + self.messages.append(json.loads(line)) + writer.close() + + async def stop(self): + self._server.close() + await self._server.wait_closed() + + +@pytest.mark.asyncio +async def test_connect_sends_ready_then_shot(socket_enabled): + fake = _FakeOGS() + await fake.start() + sim = OGSSim(host="127.0.0.1", port=fake.port, keepalive_sec=999) + await sim.connect() + await sim.send_shot(ShotData(speed=99.0, launch_angle=12.0, side_angle=1.0, + back_spin=2500, side_spin=200)) + await asyncio.sleep(0.05) + await sim.disconnect() + await fake.stop() + + assert sim.status == "off" + types = [m["type"] for m in fake.messages] + assert types[0] == "device" and fake.messages[0]["status"] == "ready" + assert "shot" in types + + +@pytest.mark.asyncio +async def test_connect_failure_sets_error_status(socket_enabled): + # Nothing listening on this port. + sim = OGSSim(host="127.0.0.1", port=1, keepalive_sec=999) + await sim.connect() + await asyncio.sleep(0.05) + assert sim.status in ("error", "connecting") + await sim.disconnect() From d20f4758cbe3cb6a719a276dc956e5dd6d7190f0 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 28 Jun 2026 18:57:00 -0400 Subject: [PATCH 033/156] make ogs socket tests work without the global pytest-socket plugin --- Software/web-server/tests/test_ogs_sim.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Software/web-server/tests/test_ogs_sim.py b/Software/web-server/tests/test_ogs_sim.py index c4919fb2..d823e78f 100644 --- a/Software/web-server/tests/test_ogs_sim.py +++ b/Software/web-server/tests/test_ogs_sim.py @@ -55,8 +55,17 @@ async def stop(self): await self._server.wait_closed() +@pytest.fixture(autouse=True) +def _enable_sockets_if_plugin_present(): + try: + import pytest_socket + pytest_socket.enable_socket() + except ImportError: + pass + + @pytest.mark.asyncio -async def test_connect_sends_ready_then_shot(socket_enabled): +async def test_connect_sends_ready_then_shot(): fake = _FakeOGS() await fake.start() sim = OGSSim(host="127.0.0.1", port=fake.port, keepalive_sec=999) @@ -74,7 +83,7 @@ async def test_connect_sends_ready_then_shot(socket_enabled): @pytest.mark.asyncio -async def test_connect_failure_sets_error_status(socket_enabled): +async def test_connect_failure_sets_error_status(): # Nothing listening on this port. sim = OGSSim(host="127.0.0.1", port=1, keepalive_sec=999) await sim.connect() From 2045c640fcd95ecd34907474bcd5a9cb42997ead Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 28 Jun 2026 22:07:23 -0400 Subject: [PATCH 034/156] add SimManager for config-driven sim fan-out and lifecycle --- Software/web-server/sim_manager.py | 74 ++++++++++++++++ Software/web-server/tests/test_sim_manager.py | 86 +++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 Software/web-server/sim_manager.py create mode 100644 Software/web-server/tests/test_sim_manager.py diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py new file mode 100644 index 00000000..a968e26d --- /dev/null +++ b/Software/web-server/sim_manager.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import logging +from typing import Awaitable, Callable, Dict, List, Optional + +from models import ShotData +from sim_interface import SimInterface +from sims.ogs_sim import OGSSim + +logger = logging.getLogger(__name__) + +BroadcastFn = Callable[[Dict[str, object]], Awaitable[None]] + + +class SimManager: + def __init__(self, config_manager, broadcast: Optional[BroadcastFn] = None) -> None: + self.config_manager = config_manager + self._broadcast = broadcast + self._sims: Dict[str, SimInterface] = {} + + def build_sims(self) -> None: + self._sims = {} + if self.config_manager.get_config("simulators.ogs.enabled"): + sim = OGSSim( + host=self.config_manager.get_config("simulators.ogs.host") or "", + port=int(self.config_manager.get_config("simulators.ogs.port") or 3111), + keepalive_sec=int(self.config_manager.get_config("simulators.ogs.keepalive_sec") or 5), + ) + sim.set_status_callback(self._broadcast_status) + self._sims[sim.name] = sim + + async def start(self) -> None: + self.build_sims() + for sim in self._sims.values(): + if self.config_manager.get_config(f"simulators.{sim.name}.auto_connect"): + await sim.connect() + await self._broadcast_status() + + async def stop(self) -> None: + for sim in self._sims.values(): + try: + await sim.disconnect() + except Exception as e: + logger.warning(f"sim {sim.name} disconnect failed: {e}") + + async def on_shot(self, shot: ShotData) -> None: + for name, sim in self._sims.items(): + try: + await sim.send_shot(shot) + except Exception as e: + logger.warning(f"sim {name} send_shot failed: {e}") + + async def connect(self, name: str) -> None: + sim = self._sims.get(name) + if sim is None: + raise KeyError(name) + await sim.connect() + + async def disconnect(self, name: str) -> None: + sim = self._sims.get(name) + if sim is None: + raise KeyError(name) + await sim.disconnect() + + def status(self) -> List[Dict[str, str]]: + return [sim.info() for sim in self._sims.values()] + + async def _broadcast_status(self) -> None: + if self._broadcast is None: + return + try: + await self._broadcast({"type": "sim_status", "sims": self.status()}) + except Exception as e: + logger.warning(f"sim status broadcast failed: {e}") diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py new file mode 100644 index 00000000..3b9d53fa --- /dev/null +++ b/Software/web-server/tests/test_sim_manager.py @@ -0,0 +1,86 @@ +import asyncio + +import pytest + +from models import ShotData +from sim_manager import SimManager + + +class _StubConfig: + def __init__(self, values): + self._v = values + + def get_config(self, key): + return self._v.get(key) + + +class _StubSim: + def __init__(self): + self.name = "stub" + self.display_name = "Stub" + self.shots = [] + self.connected = False + self._cb = None + + def set_status_callback(self, cb): + self._cb = cb + + async def connect(self): + self.connected = True + + async def disconnect(self): + self.connected = False + + async def send_shot(self, shot): + self.shots.append(shot) + + def info(self): + return {"name": self.name, "status": "connected" if self.connected else "off"} + + +@pytest.mark.asyncio +async def test_disabled_ogs_creates_no_sims(): + cfg = _StubConfig({"simulators.ogs.enabled": False}) + mgr = SimManager(cfg, broadcast=None) + mgr.build_sims() + assert mgr.status() == [] + + +@pytest.mark.asyncio +async def test_enabled_ogs_is_built(): + cfg = _StubConfig({ + "simulators.ogs.enabled": True, "simulators.ogs.auto_connect": False, + "simulators.ogs.host": "1.2.3.4", "simulators.ogs.port": 3111, + "simulators.ogs.keepalive_sec": 5, + }) + mgr = SimManager(cfg, broadcast=None) + mgr.build_sims() + names = [s["name"] for s in mgr.status()] + assert names == ["ogs"] + + +@pytest.mark.asyncio +async def test_on_shot_fans_out_and_isolates_failures(): + mgr = SimManager(_StubConfig({}), broadcast=None) + good = _StubSim() + + class _Boom(_StubSim): + async def send_shot(self, shot): + raise RuntimeError("dead sim") + + bad = _Boom() + mgr._sims = {"good": good, "boom": bad} + shot = ShotData(speed=100) + await mgr.on_shot(shot) # must not raise + assert good.shots == [shot] + + +@pytest.mark.asyncio +async def test_connect_disconnect_by_name(): + mgr = SimManager(_StubConfig({}), broadcast=None) + sim = _StubSim() + mgr._sims = {"stub": sim} + await mgr.connect("stub") + assert sim.connected is True + await mgr.disconnect("stub") + assert sim.connected is False From 72ce2d012a9c136f25dedb2c81241cd3c352f6bb Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 30 Jun 2026 22:16:22 -0400 Subject: [PATCH 035/156] gate on_shot to connected sims and fix the stub tests to match --- Software/web-server/sim_manager.py | 2 ++ Software/web-server/tests/test_sim_manager.py | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index a968e26d..f0310715 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -45,6 +45,8 @@ async def stop(self) -> None: async def on_shot(self, shot: ShotData) -> None: for name, sim in self._sims.items(): + if sim.status != "connected": + continue try: await sim.send_shot(shot) except Exception as e: diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index 3b9d53fa..e3a27185 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -31,11 +31,15 @@ async def connect(self): async def disconnect(self): self.connected = False + @property + def status(self): + return "connected" if self.connected else "off" + async def send_shot(self, shot): self.shots.append(shot) def info(self): - return {"name": self.name, "status": "connected" if self.connected else "off"} + return {"name": self.name, "status": self.status} @pytest.mark.asyncio @@ -63,16 +67,22 @@ async def test_enabled_ogs_is_built(): async def test_on_shot_fans_out_and_isolates_failures(): mgr = SimManager(_StubConfig({}), broadcast=None) good = _StubSim() + good.connected = True class _Boom(_StubSim): async def send_shot(self, shot): raise RuntimeError("dead sim") - bad = _Boom() - mgr._sims = {"good": good, "boom": bad} + boom = _Boom() + boom.connected = True + + skipped = _StubSim() # left disconnected — should receive nothing + + mgr._sims = {"good": good, "boom": boom, "skipped": skipped} shot = ShotData(speed=100) - await mgr.on_shot(shot) # must not raise + await mgr.on_shot(shot) # must not raise even though boom raises assert good.shots == [shot] + assert skipped.shots == [] @pytest.mark.asyncio From 3924382897f2b1038d43b018615495340df5dec4 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 30 Jun 2026 23:38:54 -0400 Subject: [PATCH 036/156] wire SimManager into the server with /api/sims and /ws/sims --- Software/web-server/server.py | 45 ++++++++++++++++++++++ Software/web-server/tests/test_sims_api.py | 11 ++++++ 2 files changed, 56 insertions(+) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 6af391eb..850b8a9a 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -37,6 +37,7 @@ from models import ShotData from parsers import ShotDataParser from pitrac_manager import PiTracProcessManager +from sim_manager import SimManager from strobe_calibration_manager import StrobeCalibrationManager from testing_tools_manager import TestingToolsManager from update_manager import UpdateManager @@ -206,6 +207,11 @@ def __init__(self): self.session_timeout_minutes = metadata.get("storage", {}).get("sessionTimeoutMinutes", {}).get("default", 30) self.image_cap_mb = metadata.get("storage", {}).get("imageStorageCapMB", {}).get("default", 5000) self.retention = ImageRetention(self.session_repo, self.shot_repo, IMAGES_DIR, self.image_cap_mb) + self.sim_connection_manager = ConnectionManager() + self.sim_manager = SimManager( + self.config_manager, + broadcast=self.sim_connection_manager.broadcast, + ) self.shutdown_flag = False self.background_tasks: set[asyncio.Task] = set() self._active_cameras: Dict[int, str] = {} # camera_index -> endpoint name @@ -274,6 +280,38 @@ async def reset_shot() -> Dict[str, Optional[str]]: logger.info("Shot data reset via API") return {"status": "reset", "timestamp": shot_data.timestamp} + @self.app.get("/api/sims") + async def get_sims() -> Dict[str, Any]: + return {"sims": self.sim_manager.status()} + + @self.app.post("/api/sims/{name}/connect") + async def connect_sim(name: str) -> Dict[str, Any]: + try: + await self.sim_manager.connect(name) + except KeyError: + raise HTTPException(status_code=404, detail=f"Unknown sim: {name}") + return {"sims": self.sim_manager.status()} + + @self.app.post("/api/sims/{name}/disconnect") + async def disconnect_sim(name: str) -> Dict[str, Any]: + try: + await self.sim_manager.disconnect(name) + except KeyError: + raise HTTPException(status_code=404, detail=f"Unknown sim: {name}") + return {"sims": self.sim_manager.status()} + + @self.app.websocket("/ws/sims") + async def websocket_sims(websocket: WebSocket) -> None: + await self.sim_connection_manager.connect(websocket) + try: + await websocket.send_json({"type": "sim_status", "sims": self.sim_manager.status()}) + while True: + await websocket.receive_text() + except WebSocketDisconnect: + self.sim_connection_manager.disconnect(websocket) + except Exception: + self.sim_connection_manager.disconnect(websocket) + @self.app.post("/api/internal/shot-result") async def receive_shot_result(request: Request) -> Dict[str, str]: """Receives shot results from the C++ pitrac_lm process via HTTP POST.""" @@ -323,6 +361,9 @@ async def receive_shot_result(request: Request) -> Dict[str, str]: self.shot_store.update(shot_data) await self.connection_manager.broadcast(shot_data.to_dict()) + task = asyncio.create_task(self.sim_manager.on_shot(shot_data)) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) return {"status": "ok"} @self.app.post("/api/internal/image-ready") @@ -1476,6 +1517,8 @@ async def startup_event(self) -> None: self.background_tasks.add(task) task.add_done_callback(self.background_tasks.discard) + await self.sim_manager.start() + logger.info("PiTrac Web Server ready — receiving results via HTTP POST") async def _run_tool_async(self, tool_id: str) -> None: @@ -1517,6 +1560,8 @@ async def shutdown_event(self) -> None: self.shutdown_flag = True + await self.sim_manager.stop() + loop = asyncio.get_event_loop() current_tasks = {t for t in self.background_tasks if t.get_loop() is loop} for task in current_tasks: diff --git a/Software/web-server/tests/test_sims_api.py b/Software/web-server/tests/test_sims_api.py index 0807056a..24bb1c9e 100644 --- a/Software/web-server/tests/test_sims_api.py +++ b/Software/web-server/tests/test_sims_api.py @@ -5,3 +5,14 @@ def test_ogs_config_defaults_resolve(server_instance): assert cm.get_config("simulators.ogs.host") == "" assert cm.get_config("simulators.ogs.port") == 3111 assert cm.get_config("simulators.ogs.keepalive_sec") == 5 + + +def test_get_sims_endpoint(client): + r = client.get("/api/sims") + assert r.status_code == 200 + assert isinstance(r.json().get("sims"), list) + + +def test_connect_unknown_sim_returns_404(client): + r = client.post("/api/sims/nope/connect") + assert r.status_code == 404 From c7a315be8ae37e2b0c2096518e1973578ba7ea1c Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 1 Jul 2026 18:31:17 -0400 Subject: [PATCH 037/156] only forward real shots to sims, not status or fake-hit messages --- Software/web-server/server.py | 7 +-- Software/web-server/tests/test_sims_api.py | 60 ++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 850b8a9a..4af73f42 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -361,9 +361,10 @@ async def receive_shot_result(request: Request) -> Dict[str, str]: self.shot_store.update(shot_data) await self.connection_manager.broadcast(shot_data.to_dict()) - task = asyncio.create_task(self.sim_manager.on_shot(shot_data)) - self.background_tasks.add(task) - task.add_done_callback(self.background_tasks.discard) + if not is_status and not is_fake_hit: + task = asyncio.create_task(self.sim_manager.on_shot(shot_data)) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) return {"status": "ok"} @self.app.post("/api/internal/image-ready") diff --git a/Software/web-server/tests/test_sims_api.py b/Software/web-server/tests/test_sims_api.py index 24bb1c9e..fa8ff9db 100644 --- a/Software/web-server/tests/test_sims_api.py +++ b/Software/web-server/tests/test_sims_api.py @@ -1,3 +1,8 @@ +import time + +from models import ShotData + + def test_ogs_config_defaults_resolve(server_instance): cm = server_instance.config_manager assert cm.get_config("simulators.ogs.enabled") is False @@ -16,3 +21,58 @@ def test_get_sims_endpoint(client): def test_connect_unknown_sim_returns_404(client): r = client.post("/api/sims/nope/connect") assert r.status_code == 404 + + +def _install_on_shot_spy(server_instance): + """Replace sim_manager.on_shot with an async spy that records its calls.""" + calls = [] + + async def spy(shot): + calls.append(shot) + + server_instance.sim_manager.on_shot = spy + return calls + + +def _wait_for(predicate, timeout=1.0): + """Poll until predicate() is true or timeout, so the fire-and-forget task can run.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.01) + return predicate() + + +def test_real_shot_is_forwarded_to_sims(client, server_instance): + # result_type 7 = "Hit" with no fake-hit message -> a real shot + calls = _install_on_shot_spy(server_instance) + + r = client.post("/api/internal/shot-result", json={ + "result_type": 7, + "speed_mps": 45.0, + "launch_angle": 12, + "side_angle": 1, + "back_spin": 2500, + "side_spin": 100, + }) + assert r.status_code == 200 + + assert _wait_for(lambda: len(calls) == 1) + assert len(calls) == 1 + assert isinstance(calls[0], ShotData) + + +def test_status_or_fake_hit_is_not_forwarded_to_sims(client, server_instance): + # result_type 7 with a fake-hit message -> must NOT be forwarded + calls = _install_on_shot_spy(server_instance) + + r = client.post("/api/internal/shot-result", json={ + "result_type": 7, + "message": "Club type was set", + }) + assert r.status_code == 200 + + # Give any erroneously-scheduled task a chance to run, then confirm none did. + _wait_for(lambda: len(calls) > 0) + assert calls == [] From f56c2e54534da8f1c8527052ec848ee135f3623a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 2 Jul 2026 23:01:40 -0400 Subject: [PATCH 038/156] add simulators navbar drawer with live status over /ws/sims --- Software/web-server/static/js/sims.js | 96 +++++++++++++++++++++++++ Software/web-server/templates/base.html | 18 +++++ 2 files changed, 114 insertions(+) create mode 100644 Software/web-server/static/js/sims.js diff --git a/Software/web-server/static/js/sims.js b/Software/web-server/static/js/sims.js new file mode 100644 index 00000000..5be9808d --- /dev/null +++ b/Software/web-server/static/js/sims.js @@ -0,0 +1,96 @@ +// sims.js - simulators drawer: live status over /ws/sims, connect/disconnect controls +(function () { + let ws = null; + + function statusColor(status) { + if (status === 'connected') return 'var(--color-success, #16a34a)'; + if (status === 'connecting' || status === 'error') return 'var(--color-warning, #d97706)'; + return 'var(--color-neutral, #9ca3af)'; + } + + function aggregate(sims) { + if (sims.some((s) => s.status === 'connected')) return 'connected'; + if (sims.some((s) => s.status === 'connecting' || s.status === 'error')) return 'connecting'; + return 'off'; + } + + function render(sims) { + const dot = document.getElementById('sims-status-dot'); + if (dot) dot.style.background = statusColor(aggregate(sims)); + + const list = document.getElementById('sims-list'); + if (!list) return; + if (!sims.length) { + list.innerHTML = + '

              No simulators enabled. Enable one in Configuration.

              '; + return; + } + list.innerHTML = sims + .map((s) => { + const connected = s.status === 'connected'; + const action = connected ? 'disconnect' : 'connect'; + const label = connected ? 'Disconnect' : 'Connect'; + return ` +
              +
              + + ${s.display_name || s.name} + ${s.status} +
              +
              ${s.target || ''} ${s.detail ? '· ' + s.detail : ''}
              + +
              `; + }) + .join(''); + + list.querySelectorAll('button[data-sim]').forEach((btn) => { + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + const r = await fetch(`/api/sims/${btn.dataset.sim}/${btn.dataset.action}`, { + method: 'POST', + }); + const data = await r.json(); + if (data.sims) render(data.sims); + } catch (e) { + console.error('sim action failed', e); + } finally { + btn.disabled = false; + } + }); + }); + } + + async function loadInitial() { + try { + const r = await fetch('/api/sims'); + const data = await r.json(); + render(data.sims || []); + } catch (e) { + console.error('failed to load sims', e); + } + } + + function connectWs() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(`${protocol}//${window.location.host}/ws/sims`); + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === 'sim_status') render(data.sims || []); + }; + ws.onclose = () => setTimeout(connectWs, 3000); + ws.onerror = () => { + if (ws) ws.close(); + }; + } + + document.addEventListener('DOMContentLoaded', () => { + const btn = document.getElementById('sims-nav-btn'); + const drawer = document.getElementById('sims-drawer'); + if (btn && drawer) { + btn.addEventListener('click', () => drawer.classList.toggle('hidden')); + } + loadInitial(); + connectWs(); + }); +})(); diff --git a/Software/web-server/templates/base.html b/Software/web-server/templates/base.html index ebf6f784..a269721e 100644 --- a/Software/web-server/templates/base.html +++ b/Software/web-server/templates/base.html @@ -44,6 +44,14 @@ {{ pitrac_btn("restart", "rotate-cw", "Restart", "desktop", "btn-warning") }} + +
              + +
              + + + + {% block content %}{% endblock %} @@ -108,6 +125,7 @@

              Strobe Calibration Required

              + {% block extra_scripts %}{% endblock %} From a75049bf2536692859de5a85eb4b1fc4ea29cfe5 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 3 Jul 2026 21:47:39 -0400 Subject: [PATCH 039/156] make ogs connect idempotent so a double connect cant leak the socket --- Software/web-server/sims/ogs_sim.py | 32 +++++++++++++---------- Software/web-server/static/js/sims.js | 6 +++-- Software/web-server/tests/test_ogs_sim.py | 17 ++++++++++++ 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Software/web-server/sims/ogs_sim.py b/Software/web-server/sims/ogs_sim.py index 7f01b77f..e6f5b4f2 100644 --- a/Software/web-server/sims/ogs_sim.py +++ b/Software/web-server/sims/ogs_sim.py @@ -56,6 +56,7 @@ def __init__(self, host: str, port: int = 3111, keepalive_sec: int = 5) -> None: self._keepalive_task: Optional[asyncio.Task] = None self._reconnect_task: Optional[asyncio.Task] = None self._want_connected = False + self._conn_lock = asyncio.Lock() def info(self) -> Dict[str, str]: data = super().info() @@ -67,20 +68,23 @@ async def connect(self) -> None: await self._open() async def _open(self) -> None: - if not self.host: - await self._set_status(STATUS_ERROR, "no host configured") - return - await self._set_status(STATUS_CONNECTING, f"{self.host}:{self.port}") - try: - self._reader, self._writer = await asyncio.open_connection(self.host, self.port) - except Exception as e: - logger.warning(f"OGS connect failed: {e}") - await self._set_status(STATUS_ERROR, str(e)) - self._schedule_reconnect() - return - await self._send_obj({"type": "device", "status": "ready"}) - await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") - self._keepalive_task = asyncio.create_task(self._keepalive_loop()) + async with self._conn_lock: + if self._writer is not None: + return + if not self.host: + await self._set_status(STATUS_ERROR, "no host configured") + return + await self._set_status(STATUS_CONNECTING, f"{self.host}:{self.port}") + try: + self._reader, self._writer = await asyncio.open_connection(self.host, self.port) + except Exception as e: + logger.warning(f"OGS connect failed: {e}") + await self._set_status(STATUS_ERROR, str(e)) + self._schedule_reconnect() + return + await self._send_obj({"type": "device", "status": "ready"}) + await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") + self._keepalive_task = asyncio.create_task(self._keepalive_loop()) async def _send_obj(self, obj: Dict[str, object]) -> None: if self._writer is None: diff --git a/Software/web-server/static/js/sims.js b/Software/web-server/static/js/sims.js index 5be9808d..51ea8368 100644 --- a/Software/web-server/static/js/sims.js +++ b/Software/web-server/static/js/sims.js @@ -28,8 +28,10 @@ list.innerHTML = sims .map((s) => { const connected = s.status === 'connected'; + const connecting = s.status === 'connecting'; const action = connected ? 'disconnect' : 'connect'; - const label = connected ? 'Disconnect' : 'Connect'; + const label = connecting ? 'Connecting…' : connected ? 'Disconnect' : 'Connect'; + const disabled = connecting ? 'disabled' : ''; return `
              @@ -38,7 +40,7 @@ ${s.status}
              ${s.target || ''} ${s.detail ? '· ' + s.detail : ''}
              - +
              `; }) .join(''); diff --git a/Software/web-server/tests/test_ogs_sim.py b/Software/web-server/tests/test_ogs_sim.py index d823e78f..60a2f287 100644 --- a/Software/web-server/tests/test_ogs_sim.py +++ b/Software/web-server/tests/test_ogs_sim.py @@ -35,6 +35,7 @@ class _FakeOGS: """Minimal asyncio TCP server that records newline-delimited JSON it receives.""" def __init__(self): self.messages = [] + self.connection_count = 0 self._server = None self.port = None @@ -43,6 +44,7 @@ async def start(self): self.port = self._server.sockets[0].getsockname()[1] async def _handle(self, reader, writer): + self.connection_count += 1 while True: line = await reader.readline() if not line: @@ -82,6 +84,21 @@ async def test_connect_sends_ready_then_shot(): assert "shot" in types +@pytest.mark.asyncio +async def test_double_connect_does_not_leak(): + fake = _FakeOGS() + await fake.start() + sim = OGSSim(host="127.0.0.1", port=fake.port, keepalive_sec=999) + await sim.connect() + first_task = sim._keepalive_task + await sim.connect() # second connect must be a no-op + await asyncio.sleep(0.05) + assert sim._keepalive_task is first_task # keepalive task not replaced + assert fake.connection_count == 1 # only one socket opened + await sim.disconnect() + await fake.stop() + + @pytest.mark.asyncio async def test_connect_failure_sets_error_status(): # Nothing listening on this port. From 3556cc60ba3f906b7fc8a8b9f02c8c0b79702d05 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 4 Jul 2026 09:17:50 -0400 Subject: [PATCH 040/156] read camera type, lens and orientation from config instead of env --- .../ImageProcessing/gs_config.cpp | 100 ++++++------------ 1 file changed, 30 insertions(+), 70 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index 424d77d8..6d6be3c7 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -176,6 +176,12 @@ GolfSimConfiguration::PiModel GolfSimConfiguration::GetPiModel() { } +static std::string GetConfigString(const std::string& tag_name) { + std::string value; + GolfSimConfiguration::SetConstant(tag_name, value); + return value; +} + bool GolfSimConfiguration::ReadValues() { // Many constants are read in the modules that own those constants. But some are better @@ -246,87 +252,41 @@ bool GolfSimConfiguration::ReadValues() { #endif - // Read any environment variables that we may need - - std::string slot1_env = safe_getenv("PITRAC_SLOT1_CAMERA_TYPE"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_CAMERA_TYPE environment variable was: " + slot1_env ); - if (slot1_env.empty()) { - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_CAMERA_TYPE environment variable was not set. Assuming default of: " + std::to_string(GolfSimCamera::kSystemSlot1CameraType)); - } else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces. Visual Studio seems to add them? - slot1_env = slot1_env.substr(0,1); -#endif - GolfSimCamera::kSystemSlot1CameraType = CameraHardware::string_to_camera_model(slot1_env); - } - - std::string slot2_env = safe_getenv("PITRAC_SLOT2_CAMERA_TYPE"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT2_CAMERA_TYPE environment variable was: " + slot2_env ); - if (slot2_env.empty()) { - GS_LOG_TRACE_MSG(error, "PITRAC_SLOT2_CAMERA_TYPE must be set. Exiting."); - return false; - } else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces. Visual Studio seems to add them? - slot2_env = slot2_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot2CameraType = CameraHardware::string_to_camera_model(slot2_env); - } - - std::string slot1_lens_env = safe_getenv("PITRAC_SLOT1_LENS_TYPE"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_LENS_TYPE environment variable was: " + slot1_lens_env); - if (slot1_lens_env.empty()) { - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_LENS_TYPE environment variable was not set. Assuming default of: " + std::to_string(GolfSimCamera::kSystemSlot1LensType)); - } - else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces. Visual Studio seems to add them? - slot1_lens_env = slot1_lens_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot1LensType = CameraHardware::string_to_lens_type(slot1_lens_env); + std::string slot1_type = GetConfigString("cameras.slot1.type"); + if (!slot1_type.empty()) { + GolfSimCamera::kSystemSlot1CameraType = CameraHardware::string_to_camera_model(slot1_type); } - std::string slot2_lens_env = safe_getenv("PITRAC_SLOT2_LENS_TYPE"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT2_LENS_TYPE environment variable was: " + slot2_lens_env); - if (slot2_lens_env.empty()) { - GS_LOG_TRACE_MSG(error, "PITRAC_SLOT2_LENS_TYPE must be set. Exiting."); + std::string slot2_type = GetConfigString("cameras.slot2.type"); + if (slot2_type.empty()) { + GS_LOG_MSG(error, "cameras.slot2.type must be set. Exiting."); return false; } - else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces - slot2_lens_env = slot2_lens_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot2LensType = CameraHardware::string_to_lens_type(slot2_lens_env); - } + GolfSimCamera::kSystemSlot2CameraType = CameraHardware::string_to_camera_model(slot2_type); + std::string slot1_lens = GetConfigString("cameras.slot1.lens"); + if (!slot1_lens.empty()) { + GolfSimCamera::kSystemSlot1LensType = CameraHardware::string_to_lens_type(slot1_lens); + } - std::string slot1_camera_orientation_env = safe_getenv("PITRAC_SLOT1_CAMERA_ORIENTATION"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_CAMERA_ORIENTATION environment variable was: " + slot1_camera_orientation_env); - if (slot1_camera_orientation_env.empty()) { - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_CAMERA_ORIENTATION environment variable was not set. Assuming default of: " + std::to_string(GolfSimCamera::kSystemSlot1CameraOrientation)); + std::string slot2_lens = GetConfigString("cameras.slot2.lens"); + if (slot2_lens.empty()) { + GS_LOG_MSG(error, "cameras.slot2.lens must be set. Exiting."); + return false; } - else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces. Visual Studio seems to add them? - slot1_camera_orientation_env = slot1_camera_orientation_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot1CameraOrientation = CameraHardware::string_to_camera_orientation(slot1_camera_orientation_env); + GolfSimCamera::kSystemSlot2LensType = CameraHardware::string_to_lens_type(slot2_lens); + + std::string slot1_orientation = GetConfigString("cameras.slot1.orientation"); + if (!slot1_orientation.empty()) { + GolfSimCamera::kSystemSlot1CameraOrientation = CameraHardware::string_to_camera_orientation(slot1_orientation); } - std::string slot2_camera_orientation_env = safe_getenv("PITRAC_SLOT2_CAMERA_ORIENTATION"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT2_CAMERA_ORIENTATION environment variable was: " + slot2_camera_orientation_env); - if (slot2_camera_orientation_env.empty()) { - GS_LOG_TRACE_MSG(error, "PITRAC_SLOT2_CAMERA_ORIENTATION must be set. Exiting."); + std::string slot2_orientation = GetConfigString("cameras.slot2.orientation"); + if (slot2_orientation.empty()) { + GS_LOG_MSG(error, "cameras.slot2.orientation must be set. Exiting."); return false; } - else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces - slot2_camera_orientation_env = slot2_camera_orientation_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot2CameraOrientation = CameraHardware::string_to_camera_orientation(slot2_camera_orientation_env); - } + GolfSimCamera::kSystemSlot2CameraOrientation = CameraHardware::string_to_camera_orientation(slot2_orientation); return true; From 6b8e6af18a7700bb1b5228bf0ec27241ca3fdf3a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 4 Jul 2026 14:09:06 -0400 Subject: [PATCH 041/156] populate launch options from config instead of the command line --- .../ImageProcessing/gs_config.cpp | 50 +++++++++++-- .../ImageProcessing/gs_options.cpp | 75 +++++++++++++------ .../LMSourceCode/ImageProcessing/gs_options.h | 5 ++ .../LMSourceCode/ImageProcessing/lm_main.cpp | 19 ++++- .../ImageProcessing/logging_tools.cpp | 35 +++++---- .../ImageProcessing/logging_tools.h | 2 + 6 files changed, 140 insertions(+), 46 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index 6d6be3c7..c38aa79c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -32,6 +32,48 @@ namespace golf_sim { GolfSimConfiguration::EnclosureType GolfSimConfiguration::kEnclosureVersion = GolfSimConfiguration::EnclosureType::kEnclosureVersion_Unknown; +static std::string GetConfigString(const std::string& tag_name) { + std::string value; + GolfSimConfiguration::SetConstant(tag_name, value); + return value; +} + +template +static void SetIfPresent(const std::string& tag_name, T& value) { + if (GolfSimConfiguration::PropertyExists(tag_name)) { + GolfSimConfiguration::SetConstant(tag_name, value); + } +} + +static void PopulateOptionsFromConfig() { + GolfSimOptions& options = GolfSimOptions::GetCommandLineOptions(); + + auto apply_enum = [&options](const std::string& tag_name, bool (GolfSimOptions::*setter)(const std::string&)) { + std::string value = GetConfigString(tag_name); + if (!value.empty() && !(options.*setter)(value)) { + GS_LOG_MSG(warning, "Ignoring unrecognized value for " + tag_name + ": " + value); + } + }; + apply_enum("gs_config.player.kGolferOrientation", &GolfSimOptions::SetGolferOrientation); + apply_enum("logging.level", &GolfSimOptions::SetLoggingLevel); + apply_enum("gs_config.logging.kArtifactSaveLevel", &GolfSimOptions::SetArtifactSaveLevel); + + SetIfPresent("gs_config.debug.kShowDebugImages", options.show_images_); + SetIfPresent("gs_config.debug.kWaitForKeyOnImages", options.wait_for_key_on_images_); + SetIfPresent("gs_config.player.kUsePracticeBalls", options.practice_ball_); + SetIfPresent("gs_config.cameras.kCamera1SearchCenterX", options.search_center_x_); + SetIfPresent("gs_config.cameras.kCamera1SearchCenterY", options.search_center_y_); + + std::string gspro_address = GetConfigString("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress"); + if (!gspro_address.empty()) { + options.gspro_host_address_ = gspro_address; + } + std::string e6_address = GetConfigString("gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress"); + if (!e6_address.empty()) { + options.e6_host_address_ = e6_address; + } +} + bool GolfSimConfiguration::Initialize(const std::string& configuration_filename) { // Obtain the config body ONCE: read the file when a path is explicitly given @@ -78,6 +120,8 @@ namespace golf_sim { return false; } + PopulateOptionsFromConfig(); + return true; } @@ -176,12 +220,6 @@ GolfSimConfiguration::PiModel GolfSimConfiguration::GetPiModel() { } -static std::string GetConfigString(const std::string& tag_name) { - std::string value; - GolfSimConfiguration::SetConstant(tag_name, value); - return value; -} - bool GolfSimConfiguration::ReadValues() { // Many constants are read in the modules that own those constants. But some are better diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.cpp b/Software/LMSourceCode/ImageProcessing/gs_options.cpp index 3eed0b7c..933c1f4f 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_options.cpp @@ -74,34 +74,14 @@ bool GolfSimOptions::Parse(int argc, char *argv[]) system_mode_ = (SystemMode)mode_table[system_mode_string_]; - std::map artifact_save_level_table = - { { "none", ArtifactSaveLevel::kNoArtifacts }, - { "final_results_only", ArtifactSaveLevel::kFinalResultsOnly }, - { "all", ArtifactSaveLevel::kAll }, - }; - if (artifact_save_level_table.count(artifact_save_level_string_) == 0) - throw std::runtime_error("Invalid system_mode: " + artifact_save_level_string_); - artifact_save_level_ = (ArtifactSaveLevel)artifact_save_level_table[artifact_save_level_string_]; - + if (!SetArtifactSaveLevel(artifact_save_level_string_)) + throw std::runtime_error("Invalid artifact_save_level: " + artifact_save_level_string_); - std::map log_level_table = - { { "trace", LoggingLevel::kTrace }, - { "debug", LoggingLevel::kDebug }, - { "info", LoggingLevel::kInfo }, - { "warn", LoggingLevel::kWarn }, - { "error", LoggingLevel::kError }, - { "none", LoggingLevel::kNone }, - }; - if (log_level_table.count(logging_level_string_) == 0) + if (!SetLoggingLevel(logging_level_string_)) throw std::runtime_error("Invalid log_level: " + logging_level_string_); - logging_level_ = (LoggingLevel)log_level_table[logging_level_string_]; - std::map orientation_table = - { { "right_handed", GolferOrientation::kRightHanded }, - { "left_handed", GolferOrientation::kLeftHanded } }; - if (mode_table.count(system_mode_string_) == 0) + if (!SetGolferOrientation(golfer_orientation_string_)) throw std::runtime_error("Invalid golfer_orientation: " + golfer_orientation_string_); - golfer_orientation_ = (GolferOrientation)orientation_table[golfer_orientation_string_]; if (help_) { @@ -119,6 +99,53 @@ bool GolfSimOptions::Parse(int argc, char *argv[]) return true; } +bool GolfSimOptions::SetArtifactSaveLevel(const std::string& level) +{ + static const std::map table = + { { "none", ArtifactSaveLevel::kNoArtifacts }, + { "final_results_only", ArtifactSaveLevel::kFinalResultsOnly }, + { "all", ArtifactSaveLevel::kAll }, + }; + auto it = table.find(level); + if (it == table.end()) + return false; + artifact_save_level_string_ = level; + artifact_save_level_ = it->second; + return true; +} + +bool GolfSimOptions::SetLoggingLevel(const std::string& level) +{ + static const std::map table = + { { "trace", LoggingLevel::kTrace }, + { "debug", LoggingLevel::kDebug }, + { "info", LoggingLevel::kInfo }, + { "warn", LoggingLevel::kWarn }, + { "error", LoggingLevel::kError }, + { "none", LoggingLevel::kNone }, + }; + auto it = table.find(level); + if (it == table.end()) + return false; + logging_level_string_ = level; + logging_level_ = it->second; + return true; +} + +bool GolfSimOptions::SetGolferOrientation(const std::string& orientation) +{ + static const std::map table = + { { "right_handed", GolferOrientation::kRightHanded }, + { "left_handed", GolferOrientation::kLeftHanded }, + }; + auto it = table.find(orientation); + if (it == table.end()) + return false; + golfer_orientation_string_ = orientation; + golfer_orientation_ = it->second; + return true; +} + void GolfSimOptions::Print() const { std::cout << "Options:" << std::endl; diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.h b/Software/LMSourceCode/ImageProcessing/gs_options.h index 76ed6f65..933c5420 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.h +++ b/Software/LMSourceCode/ImageProcessing/gs_options.h @@ -178,6 +178,11 @@ namespace golf_sim { virtual bool Parse(int argc, char* argv[]); virtual void Print() const; + // Each returns false and leaves the option unchanged if the string is not a recognized value + bool SetArtifactSaveLevel(const std::string& level); + bool SetLoggingLevel(const std::string& level); + bool SetGolferOrientation(const std::string& orientation); + protected: boost::program_options::options_description options_; static GolfSimOptions the_command_line_options_; diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 03fb6421..111e8fa8 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1501,7 +1501,24 @@ int main(int argc, char *argv[]) return 0; } - LoggingTools::logging_tool_wait_for_keypress_ = GolfSimOptions::GetCommandLineOptions().wait_for_key_on_images_; + LoggingTools::ApplyLogLevel(); + + const GolfSimOptions& options = GolfSimOptions::GetCommandLineOptions(); + GS_LOG_MSG(info, "Options from config: golfer_orientation=" + options.golfer_orientation_string_ + + ", logging_level=" + options.logging_level_string_ + + ", artifact_save_level=" + options.artifact_save_level_string_ + + ", show_images=" + std::to_string(options.show_images_) + + ", wait_keys=" + std::to_string(options.wait_for_key_on_images_) + + ", practice_ball=" + std::to_string(options.practice_ball_) + + ", search_center=" + std::to_string(options.search_center_x_) + "," + std::to_string(options.search_center_y_) + + ", gspro_host_address=" + options.gspro_host_address_ + + ", e6_host_address=" + options.e6_host_address_ + + ", slot1 type/lens/orientation=" + std::to_string(GolfSimCamera::kSystemSlot1CameraType) + "/" + + std::to_string(GolfSimCamera::kSystemSlot1LensType) + "/" + std::to_string(GolfSimCamera::kSystemSlot1CameraOrientation) + + ", slot2 type/lens/orientation=" + std::to_string(GolfSimCamera::kSystemSlot2CameraType) + "/" + + std::to_string(GolfSimCamera::kSystemSlot2LensType) + "/" + std::to_string(GolfSimCamera::kSystemSlot2CameraOrientation)); + + LoggingTools::logging_tool_wait_for_keypress_ = options.wait_for_key_on_images_; // We prefer the command-line setting even if there's one in the .json config file if (!GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_.empty()) { diff --git a/Software/LMSourceCode/ImageProcessing/logging_tools.cpp b/Software/LMSourceCode/ImageProcessing/logging_tools.cpp index e32fec3f..2b25a6f7 100644 --- a/Software/LMSourceCode/ImageProcessing/logging_tools.cpp +++ b/Software/LMSourceCode/ImageProcessing/logging_tools.cpp @@ -77,22 +77,8 @@ namespace golf_sim { - void LoggingTools::InitLogging() + void LoggingTools::ApplyLogLevel() { - boost::log::add_common_attributes(); - boost::log::core::get()->add_global_attribute("Scope", boost::log::attributes::named_scope()); - - if (!GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_.empty()) { - kBaseImageLoggingDir = GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_; - } - else { -#ifdef __unix__ - LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#else - LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#endif - } - switch (GolfSimOptions::GetCommandLineOptions().logging_level_) { case kTrace: { boost::log::core::get()->set_filter( @@ -131,6 +117,25 @@ namespace golf_sim { break; } }; + } + + void LoggingTools::InitLogging() + { + boost::log::add_common_attributes(); + boost::log::core::get()->add_global_attribute("Scope", boost::log::attributes::named_scope()); + + if (!GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_.empty()) { + kBaseImageLoggingDir = GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_; + } + else { +#ifdef __unix__ + LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; +#else + LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; +#endif + } + + ApplyLogLevel(); /* log formatter: * [TimeStamp] [ThreadId] [Severity Level] [Scope] Log message diff --git a/Software/LMSourceCode/ImageProcessing/logging_tools.h b/Software/LMSourceCode/ImageProcessing/logging_tools.h index b5cf0dde..a854b446 100644 --- a/Software/LMSourceCode/ImageProcessing/logging_tools.h +++ b/Software/LMSourceCode/ImageProcessing/logging_tools.h @@ -48,6 +48,8 @@ struct LoggingTools static void InitLogging(); + static void ApplyLogLevel(); + // Lowest-level logging function to allow for additional filtering, sinking, etc. static void InternalLog(boost::log::trivial::severity_level level, const std::string& msg); From 404ad33d8abafd029c56f915f355a2beeb848fd8 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 4 Jul 2026 17:17:11 -0400 Subject: [PATCH 042/156] drop the passVia routing metadata --- Software/web-server/CONFIGURATIONS.md | 41 +- Software/web-server/config_manager.py | 5 +- Software/web-server/configurations.json | 893 ++++++------------ .../web-server/tests/test_pitrac_manager.py | 4 +- .../web-server/tests/utils/mock_factories.py | 4 +- Software/web-server/update_configurations.py | 124 --- 6 files changed, 271 insertions(+), 800 deletions(-) delete mode 100644 Software/web-server/update_configurations.py diff --git a/Software/web-server/CONFIGURATIONS.md b/Software/web-server/CONFIGURATIONS.md index e5babdd6..7fa366a6 100644 --- a/Software/web-server/CONFIGURATIONS.md +++ b/Software/web-server/CONFIGURATIONS.md @@ -23,10 +23,6 @@ Each configuration entry is a key-value pair where the key is a dot-notation pat | `showInBasic` | boolean | Whether to show in basic/simple configuration view | | `basicSubcategory` | string | Subcategory for basic view organization | | `requiresRestart` | boolean | Whether changing this setting requires system restart | -| `passedVia` | string | How parameter is passed: `cli`, `environment`, or omitted (config file) | -| `passedTo` | string | Target component(s): `camera1`, `camera2`, `both`, `system` | -| `cliArgument` | string | Command-line argument name (when `passedVia: "cli"`) | -| `envVariable` | string | Environment variable name (when `passedVia: "environment"`) | | `visibleWhen` | object | Conditional visibility based on other settings | | `affectsSettings` | array | List of other settings affected by this one | @@ -59,34 +55,6 @@ Available categories for organizing settings: - `Spin Analysis` - Spin calculation settings - `Advanced` - Expert-level parameters -## Parameter Passing Methods - -### 1. CLI Arguments (`passedVia: "cli"`) -Parameters passed as command-line arguments to the pitrac_lm binary. -- Requires `cliArgument` field -- Format: `--parameter_name=value` or `--flag` (for booleans) -- Target specified by `passedTo` field - -### 2. Environment Variables (`passedVia: "environment"`) -Parameters set as environment variables before process startup. -- Requires `envVariable` field -- Format: `PITRAC_VARIABLE_NAME=value` -- Target specified by `passedTo` field - -### 3. Config File (no `passedVia` field) -Parameters written to `/etc/pitrac/golf_sim_config.json`. -- Default method when `passedVia` is omitted -- Accessible to all components - -## Target Components (`passedTo`) - -| Value | Description | Usage | -|-------|-------------|--------| -| `camera1` | Camera 1 process only | Settings specific to first camera | -| `camera2` | Camera 2 process only | Settings specific to second camera (single Pi mode) | -| `both` | Both camera processes | Settings that apply to both cameras | -| `system` | System-wide | Non-camera settings (simulators, storage, etc.) | - ## Conditional Visibility Use `visibleWhen` to show/hide settings based on other configuration values: @@ -118,10 +86,7 @@ Use `affectsSettings` to indicate which other settings are impacted: "max": 1920, "step": 10, "default": 850, - "requiresRestart": true, - "passedVia": "cli", - "passedTo": "camera1", - "cliArgument": "--search_center_x" + "requiresRestart": true } ``` @@ -140,7 +105,5 @@ Settings shown in basic view are organized into subcategories with display order ## Notes -- Settings without `passedVia` are stored in the config file and accessible to all components -- Boolean CLI arguments are passed as flags without values (e.g., `--practice_ball`) -- Environment variables are set per-process when `passedTo` specifies a target +- pitrac_lm reads every setting from the merged config served at `/api/internal/config` - The `requiresRestart` flag triggers automatic process restart when changed via web UI \ No newline at end of file diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index 4cea57ca..627317ae 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -575,8 +575,7 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: return True, "" def build_generated_config(self) -> Dict[str, Any]: - """Build the merged config dict served to pitrac_lm (defaults + calibration + user settings, - all settings regardless of passedVia).""" + """Build the merged config dict served to pitrac_lm (defaults + calibration + user settings).""" config = {} metadata = self.load_configurations_metadata() settings_metadata = metadata.get("settings", {}) @@ -600,7 +599,7 @@ def generate_golf_sim_config(self) -> Path: """Generate golf_sim_config.json from configurations metadata and user settings This method creates a complete golf_sim_config.json file by: - 1. Taking all settings marked with passedVia: "json" + 1. Taking every setting in the configurations metadata 2. Getting their values (default + user overrides) 3. Building the nested JSON structure expected by pitrac_lm diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 5cd3b3b0..259ace76 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -5,14 +5,12 @@ "camera1": { "displayName": "Camera 1", "slot": "slot1", - "defaultIndex": 0, - "envPrefix": "PITRAC_SLOT1" + "defaultIndex": 0 }, "camera2": { "displayName": "Camera 2", "slot": "slot2", - "defaultIndex": 1, - "envPrefix": "PITRAC_SLOT2" + "defaultIndex": 1 } }, "systemDefaults": { @@ -246,8 +244,7 @@ "cameras.slot2.type", "cameras.slot2.lens", "gs_config.cameras.kCamera2Gain" - ], - "passedVia": "json" + ] }, "gs_config.strobing.kConnectionBoardVersion": { "category": "System", @@ -262,8 +259,7 @@ "3": "Version 3.0" }, "default": "3", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kDAC_setting": { "category": "Strobing", @@ -288,8 +284,7 @@ "3": "Version 3.0 (Circa 2026 - Streamlined tower with stacked modules)" }, "default": "2", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "system.camera_role": { "category": "System", @@ -305,8 +300,7 @@ "requiresRestart": true, "visibleWhen": { "system.mode": "dual" - }, - "passedVia": "json" + } }, "gs_config.modes.kStartInPuttingMode": { "category": "System", @@ -316,8 +310,7 @@ "description": "Start the system in putting mode for short game practice", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.player.kGolferOrientation": { "category": "System", @@ -331,9 +324,7 @@ "left_handed": "Left Handed" }, "default": "right_handed", - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--golfer_orientation" + "requiresRestart": true }, "gs_config.player.kUsePracticeBalls": { "category": "System", @@ -343,9 +334,7 @@ "description": "Enable for lightweight practice/foam balls - adjusts detection parameters (CLI parameter)", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--practice_ball" + "requiresRestart": true }, "cameras.slot1.type": { "category": "Cameras", @@ -362,9 +351,7 @@ "5": "InnoMaker IMX296 - Mono sensor (RECOMMENDED)" }, "default": "5", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT1_CAMERA_TYPE" + "requiresRestart": true }, "cameras.slot1.lens": { "category": "Cameras", @@ -378,9 +365,7 @@ "3": "Custom - Use custom lens setting (below)" }, "default": "1", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT1_LENS_TYPE" + "requiresRestart": true }, "cameras.kSlot1CustomLensFocalLength": { "category": "Cameras", @@ -388,7 +373,6 @@ "default": 4.0, "description": "Focal length when using custom lens in Slot 1 (mm)", "ui_name": "Custom Lens Focal Length Slot 1", - "passedVia": "json", "requiresRestart": true }, "cameras.slot1.orientation": { @@ -402,9 +386,7 @@ "2": "UpsideDown - Upside down orientation (system will flip)" }, "default": "1", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT1_CAMERA_ORIENTATION" + "requiresRestart": true }, "cameras.slot2.type": { "category": "Cameras", @@ -424,9 +406,7 @@ "5": "InnoMaker IMX296 - Mono sensor (RECOMMENDED)" }, "default": "5", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT2_CAMERA_TYPE" + "requiresRestart": true }, "cameras.slot2.lens": { "category": "Cameras", @@ -443,9 +423,7 @@ "3": "Custom - Use custom lens setting (below)" }, "default": "1", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT2_LENS_TYPE" + "requiresRestart": true }, "cameras.kSlot2CustomLensFocalLength": { "category": "Cameras", @@ -453,7 +431,6 @@ "default": 4.0, "description": "Focal length when using custom lens in Slot 2 (mm)", "ui_name": "Custom Lens Focal Length Slot 2", - "passedVia": "json", "requiresRestart": true }, "cameras.slot2.orientation": { @@ -467,9 +444,7 @@ "2": "UpsideDown - Upside down orientation (system will flip)" }, "default": "1", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT2_CAMERA_ORIENTATION" + "requiresRestart": true }, "logging.level": { "category": "Logging", @@ -486,9 +461,7 @@ "error": "Error - Only errors" }, "default": "info", - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--logging_level" + "requiresRestart": true }, "gs_config.logging.kArtifactSaveLevel": { "category": "Logging", @@ -503,9 +476,7 @@ "all": "All - Save all intermediate images (may slow system)" }, "default": "final_results_only", - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--artifact_save_level" + "requiresRestart": true }, "gs_config.debug.kShowDebugImages": { "category": "Debugging", @@ -514,9 +485,7 @@ "description": "Display debug/trace images on screen for troubleshooting (CLI parameter)", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--show_images" + "requiresRestart": true }, "gs_config.debug.kWaitForKeyOnImages": { "category": "Debugging", @@ -525,9 +494,7 @@ "description": "Wait for keypress after showing debug images (CLI parameter)", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--wait_keys" + "requiresRestart": true }, "gs_config.ball_identification.kStrobedBallDetectionMethod": { "category": "Ball Detection", @@ -541,8 +508,7 @@ "experimental": "YOLO Neural Network" }, "default": "experimental", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kBallPlacementDetectionMethod": { "category": "Ball Detection", @@ -556,8 +522,7 @@ "experimental": "YOLO (Fast & Accurate)" }, "default": "experimental", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kModelPath": { "category": "AI Detection", @@ -568,8 +533,7 @@ "type": "select", "options": {}, "default": "/etc/pitrac/models/yolo26-ball-detector", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kImageTypeToProcessWithYOLO": { "category": "Ball Detection", @@ -583,8 +547,7 @@ "1": "YOLO will process monochrome images (and convert as necessary)" }, "default": "0", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1Gain": { "category": "Cameras", @@ -597,8 +560,7 @@ "max": 16.0, "step": 0.1, "default": 6.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2Gain": { "category": "Cameras", @@ -614,8 +576,7 @@ "max": 16.0, "step": 0.1, "default": 3.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1Saturation": { "category": "Cameras", @@ -628,8 +589,7 @@ "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2Saturation": { "category": "Cameras", @@ -642,8 +602,7 @@ "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1SearchCenterX": { "category": "Cameras", @@ -655,9 +614,7 @@ "max": 1920, "step": 10, "default": 850, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--search_center_x" + "requiresRestart": true }, "gs_config.cameras.kCamera1SearchCenterY": { "category": "Cameras", @@ -669,9 +626,7 @@ "max": 1080, "step": 10, "default": 500, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--search_center_y" + "requiresRestart": true }, "gs_config.cameras.kHLAOffset": { "category": "Cameras", @@ -684,8 +639,7 @@ "max": 45.0, "step": 1.0, "default": 0.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.cameras.kVLAOffset": { "category": "Cameras", @@ -698,8 +652,7 @@ "max": 45.0, "step": 1.0, "default": 0.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress": { "category": "Simulators", @@ -709,9 +662,7 @@ "description": "IP address of E6 Connect simulator", "type": "ip_address", "default": "", - "requiresRestart": false, - "passedVia": "cli", - "cliArgument": "--e6_host_address" + "requiresRestart": false }, "gs_config.golf_simulator_interfaces.E6.kE6ConnectPort": { "category": "Simulators", @@ -723,8 +674,7 @@ "min": 1, "max": 65535, "default": 2483, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress": { "category": "Simulators", @@ -734,9 +684,7 @@ "description": "IP address of GSPro simulator (e.g., 192.168.1.100)", "type": "ip_address", "default": "", - "requiresRestart": false, - "passedVia": "cli", - "cliArgument": "--gspro_host_address" + "requiresRestart": false }, "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort": { "category": "Simulators", @@ -748,8 +696,7 @@ "min": 1, "max": 65535, "default": 921, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "simulators.ogs.enabled": { "category": "Simulators", @@ -759,8 +706,7 @@ "description": "Show OpenGolfSim in the simulators drawer and allow connecting", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "python" + "requiresRestart": false }, "simulators.ogs.auto_connect": { "category": "Simulators", @@ -770,8 +716,7 @@ "description": "Open the OpenGolfSim connection automatically when the server starts", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "python" + "requiresRestart": false }, "simulators.ogs.host": { "category": "Simulators", @@ -781,8 +726,7 @@ "description": "IP address of the device running OpenGolfSim (e.g. the iPad)", "type": "ip_address", "default": "", - "requiresRestart": false, - "passedVia": "python" + "requiresRestart": false }, "simulators.ogs.port": { "category": "Simulators", @@ -794,8 +738,7 @@ "min": 1, "max": 65535, "default": 3111, - "requiresRestart": false, - "passedVia": "python" + "requiresRestart": false }, "simulators.ogs.keepalive_sec": { "category": "Simulators", @@ -807,8 +750,7 @@ "min": 1, "max": 60, "default": 5, - "requiresRestart": false, - "passedVia": "python" + "requiresRestart": false }, "gs_config.ball_identification.kUseCLAHEProcessing": { "category": "Ball Detection", @@ -818,8 +760,7 @@ "description": "Enable Contrast Limited Adaptive Histogram Equalization for better ball detection", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kCLAHEClipLimit": { "category": "Ball Detection", @@ -831,8 +772,7 @@ "min": 1, "max": 40, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kExpectedBallRadiusPixelsAt40cm": { "category": "Ball Detection", @@ -844,8 +784,7 @@ "min": 10, "max": 200, "default": 87, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kStandardBallSpeedSlowdownPercentage": { "category": "Strobing", @@ -858,8 +797,7 @@ "max": 10, "step": 0.1, "default": 0.1, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kPracticeBallSpeedSlowdownPercentage": { "category": "Strobing", @@ -872,8 +810,7 @@ "max": 20, "step": 0.1, "default": 4.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kPuttingBallSpeedSlowdownPercentage": { "category": "Strobing", @@ -886,8 +823,7 @@ "max": 20, "step": 0.1, "default": 5.2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.number_bits_for_fast_on_pulse_": { "category": "Strobing", @@ -898,8 +834,7 @@ "min": 1, "max": 16, "default": 1, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.number_bits_for_slow_on_pulse_": { "category": "Strobing", @@ -910,8 +845,7 @@ "min": 1, "max": 16, "default": 1, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPrimingPulseFPS": { "category": "Strobing", @@ -922,8 +856,7 @@ "min": 5, "max": 60, "default": 15, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kCameraRequiresFlushPulse": { "category": "Strobing", @@ -932,8 +865,7 @@ "description": "Send an extra trigger pulse after the shutter pulse to flush the ball-hit frame out of the sensor. Required for some IMX296 boards (e.g. Pi GS) where one trigger only advances the capture FSM. Leave off for InnoMaker — it produces the second frame event natively.", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.logging.kLogIntermediateExposureImagesToFile": { "category": "Logging", @@ -943,8 +875,7 @@ "description": "Save intermediate exposure images for debugging", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.logging.kLogIntermediateSpinImagesToFile": { "category": "Logging", @@ -954,8 +885,7 @@ "description": "Save spin analysis images", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.logging.kLogWebserverImagesToFile": { "category": "Logging", @@ -965,8 +895,7 @@ "description": "Save images for web server display", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.logging.kLogDiagnosticImagesToUniqueFiles": { "category": "Logging", @@ -976,8 +905,7 @@ "description": "Save diagnostic images with unique names (vs overwriting)", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.logging.kLinuxBaseImageLoggingDir": { "category": "Storage", @@ -987,9 +915,7 @@ "description": "Base directory for saving images", "type": "path", "default": "~/LM_Shares/Images/", - "requiresRestart": false, - "passedVia": "cli", - "cliArgument": "--base_image_logging_dir" + "requiresRestart": false }, "gs_config.ipc_interface.kWebServerShareDirectory": { "category": "Network", @@ -999,8 +925,7 @@ "description": "Directory for web server shared files", "type": "path", "default": "~/LM_Shares/Images/", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ipc_interface.kWebServerTomcatShareDirectory": { "category": "Network", @@ -1010,8 +935,7 @@ "description": "Directory for Tomcat web server files", "type": "path", "default": "~/LM_Shares/WebShare", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ipc_interface.kRefreshTimeSeconds": { "category": "Network", @@ -1023,8 +947,7 @@ "min": 1, "max": 60, "default": 3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.golf_simulator_interfaces.kSkipSpinCalculation": { "category": "Spin Analysis", @@ -1034,8 +957,7 @@ "description": "Disable spin calculation (for putting-only or debugging)", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.golf_simulator_interfaces.kWriteSpinAnalysisCsvFiles": { "category": "Spin Analysis", @@ -1045,8 +967,7 @@ "description": "Save spin analysis data to CSV files", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kModelConfidenceThreshold": { "category": "AI Detection", @@ -1058,8 +979,7 @@ "max": 1.0, "step": 0.05, "default": 0.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kModelNMSThreshold": { "category": "AI Detection", @@ -1071,8 +991,7 @@ "max": 1.0, "step": 0.05, "default": 0.4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kModelInputWidth": { "category": "AI Detection", @@ -1084,8 +1003,7 @@ "max": 2048, "step": 32, "default": 736, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kModelInputHeight": { "category": "AI Detection", @@ -1097,8 +1015,7 @@ "max": 2048, "step": 32, "default": 544, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kInferenceThreads": { "category": "AI Detection", @@ -1110,8 +1027,7 @@ "max": 8, "step": 1, "default": 4, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kStrobedBallsCannyLower": { "category": "Ball Detection", @@ -1122,8 +1038,7 @@ "min": 0, "max": 255, "default": 33, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsCannyUpper": { "category": "Ball Detection", @@ -1134,8 +1049,7 @@ "min": 0, "max": 255, "default": 90, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kCLAHETilesGridSize": { "category": "Ball Detection", @@ -1146,38 +1060,7 @@ "min": 2, "max": 16, "default": 6, - "requiresRestart": false, - "passedVia": "json" - }, - "cli.system_mode": { - "category": "System", - "subcategory": "advanced", - "displayName": "CLI System Mode", - "description": "System mode passed via command line", - "type": "select", - "options": { - "camera1": "Normal Mode", - "test_gspro_server": "Test GSPro Server", - "test_images": "Test Images", - "test_spin": "Test Spin" - }, - "default": "camera1", - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--system_mode", - "internal": true - }, - "cli.run_single_pi": { - "category": "System", - "subcategory": "advanced", - "displayName": "Run Single Pi Mode (CLI)", - "description": "Run both cameras on a single Pi (overrides system.mode setting)", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--run_single_pi", - "internal": true + "requiresRestart": false }, "gs_config.cameras.kCamera1HighFPSGain": { "category": "Cameras", @@ -1189,8 +1072,7 @@ "max": 20.0, "step": 0.1, "default": 15.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1Contrast": { "category": "Cameras", @@ -1202,8 +1084,7 @@ "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2Contrast": { "category": "Cameras", @@ -1215,8 +1096,7 @@ "max": 2.0, "step": 0.1, "default": 1.2, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2ComparisonGain": { "category": "Cameras", @@ -1228,8 +1108,7 @@ "max": 16.0, "step": 0.1, "default": 0.8, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2CalibrateOrLocationGain": { "category": "Cameras", @@ -1241,8 +1120,7 @@ "max": 16.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2PuttingGain": { "category": "Cameras", @@ -1254,8 +1132,7 @@ "max": 16.0, "step": 0.1, "default": 4.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2PuttingContrast": { "category": "Cameras", @@ -1267,8 +1144,7 @@ "max": 2.0, "step": 0.1, "default": 1.2, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1StillShutterTimeuS": { "category": "Cameras", @@ -1279,8 +1155,7 @@ "min": 1000, "max": 100000, "default": 40000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2StillShutterTimeuS": { "category": "Cameras", @@ -1291,8 +1166,7 @@ "min": 1000, "max": 100000, "default": 15000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.physical_constants.kBallRadiusMeters": { "category": "System", @@ -1305,8 +1179,7 @@ "step": 1e-06, "default": 0.021335, "requiresRestart": false, - "internal": true, - "passedVia": "json" + "internal": true }, "gs_config.ball_position.kMinMovedBallRadiusRatio": { "category": "Ball Detection", @@ -1318,8 +1191,7 @@ "max": 1.0, "step": 0.05, "default": 0.6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMaxMovedBallRadiusRatio": { "category": "Ball Detection", @@ -1331,8 +1203,7 @@ "max": 2.0, "step": 0.05, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMinRadiusRatio": { "category": "Ball Detection", @@ -1344,8 +1215,7 @@ "max": 1.0, "step": 0.05, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMaxRadiusRatio": { "category": "Ball Detection", @@ -1357,8 +1227,7 @@ "max": 2.5, "step": 0.05, "default": 1.7, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMinRadiusOffset": { "category": "Ball Detection", @@ -1369,8 +1238,7 @@ "min": 0, "max": 50, "default": 20, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMaxRadiusOffset": { "category": "Ball Detection", @@ -1381,8 +1249,7 @@ "min": 0, "max": 50, "default": 20, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kTeedBallSearchAreaMaskRadiusRatio": { "category": "Ball Detection", @@ -1394,8 +1261,7 @@ "max": 2.0, "step": 0.1, "default": 0.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMinBallRadiusPixelsForProximityWarning": { "category": "Ball Detection", @@ -1406,8 +1272,7 @@ "min": 50, "max": 300, "default": 160, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kBaudRateForFastPulses": { "category": "Strobing", @@ -1418,8 +1283,7 @@ "min": 9600, "max": 921600, "default": 115200, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kBaudRateForSlowPulses": { "category": "Strobing", @@ -1430,8 +1294,7 @@ "min": 9600, "max": 921600, "default": 115200, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kBaudRatePulseMultiplier": { "category": "Strobing", @@ -1443,8 +1306,7 @@ "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kCam2SetupPeriodMilliseconds": { "category": "Strobing", @@ -1455,8 +1317,7 @@ "min": 500, "max": 5000, "default": 2000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kNumberPrimingPulses": { "category": "Strobing", @@ -1467,8 +1328,7 @@ "min": 1, "max": 30, "default": 12, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPuttingStrobeDelayMs": { "category": "Strobing", @@ -1479,8 +1339,7 @@ "min": 10, "max": 200, "default": 50, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kPauseBeforeReadyForFinalPrimingPulseMs": { "category": "Strobing", @@ -1491,8 +1350,7 @@ "min": 100, "max": 1000, "default": 400, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseBeforeSendingPreImageTriggerMs": { "category": "Strobing", @@ -1503,8 +1361,7 @@ "min": 100, "max": 1000, "default": 300, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseAfterSendingPreImageTriggerMs": { "category": "Strobing", @@ -1515,8 +1372,7 @@ "min": 500, "max": 5000, "default": 2000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseBeforeSendingImageFlushMs": { "category": "Strobing", @@ -1527,8 +1383,7 @@ "min": 100, "max": 1000, "default": 300, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseBeforeSendingFinalImageTriggerMs": { "category": "Strobing", @@ -1539,8 +1394,7 @@ "min": 500, "max": 5000, "default": 2000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseToSetUpInnoMakerExternalTriggerMilliseconds": { "category": "Strobing", @@ -1551,8 +1405,7 @@ "min": 100, "max": 2000, "default": 500, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.image_capture.kMaxWatchingCropWidth": { "category": "Cameras", @@ -1563,8 +1416,7 @@ "min": 32, "max": 256, "default": 96, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.image_capture.kMaxWatchingCropHeight": { "category": "Cameras", @@ -1575,8 +1427,7 @@ "min": 32, "max": 256, "default": 88, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.spin_analysis.kSpinDetectionMethod": { "category": "Spin Analysis", @@ -1590,8 +1441,7 @@ "legacy": "Rotation Search (Legacy)" }, "default": "ml", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.spin_analysis.kCoarseSearchResolution": { "category": "Spin Analysis", @@ -1602,8 +1452,7 @@ "min": 45, "max": 120, "default": 90, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kGaborMaxWhitePercent": { "category": "Spin Analysis", @@ -1614,8 +1463,7 @@ "min": 30, "max": 60, "default": 45, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kGaborMinWhitePercent": { "category": "Spin Analysis", @@ -1626,8 +1474,7 @@ "min": 20, "max": 50, "default": 39, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseXRotationDegreesIncrement": { "category": "Spin Analysis", @@ -1638,8 +1485,7 @@ "min": 1, "max": 10, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseXRotationDegreesStart": { "category": "Spin Analysis", @@ -1650,8 +1496,7 @@ "min": -90, "max": 0, "default": -36, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseXRotationDegreesEnd": { "category": "Spin Analysis", @@ -1662,8 +1507,7 @@ "min": 0, "max": 90, "default": 36, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseYRotationDegreesIncrement": { "category": "Spin Analysis", @@ -1674,8 +1518,7 @@ "min": 1, "max": 10, "default": 5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseYRotationDegreesStart": { "category": "Spin Analysis", @@ -1686,8 +1529,7 @@ "min": -45, "max": 0, "default": -15, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseYRotationDegreesEnd": { "category": "Spin Analysis", @@ -1698,8 +1540,7 @@ "min": 0, "max": 45, "default": 15, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseZRotationDegreesIncrement": { "category": "Spin Analysis", @@ -1710,8 +1551,7 @@ "min": 1, "max": 10, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseZRotationDegreesStart": { "category": "Spin Analysis", @@ -1722,8 +1562,7 @@ "min": -90, "max": 0, "default": -10, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseZRotationDegreesEnd": { "category": "Spin Analysis", @@ -1734,8 +1573,7 @@ "min": 0, "max": 180, "default": 110, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kSpinModelPath": { "category": "Spin Analysis", @@ -1745,8 +1583,7 @@ "type": "select", "options": {}, "default": "/etc/pitrac/models/spin-predictor", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.spin_analysis.kSpinMLZFallbackThreshold": { "category": "Spin Analysis", @@ -1757,8 +1594,7 @@ "min": 10, "max": 180, "default": 60, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ipc_interface.kMaxCam2ImageReceivedTimeMs": { "category": "Network", @@ -1769,8 +1605,7 @@ "min": 1000, "max": 60000, "default": 40000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.golf_simulator_interfaces.E6.kE6InterMessageDelayMs": { "category": "Simulators", @@ -1781,8 +1616,7 @@ "min": 0, "max": 500, "default": 50, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.golf_simulator_interfaces.kLaunchMonitorIdString": { "category": "Simulators", @@ -1791,8 +1625,7 @@ "description": "Identification string sent to golf simulators", "type": "text", "default": "PiTrac LM 0.1", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kEnableClubImages": { "category": "Club Data", @@ -1801,8 +1634,7 @@ "description": "Enable capturing club images", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.club_data.kClubImageOutputDir": { "category": "Storage", @@ -1811,8 +1643,7 @@ "description": "Directory for saving club images", "type": "path", "default": "~/LM_Shares/Images/", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kNumberFramesToSaveBeforeHit": { "category": "Club Data", @@ -1823,8 +1654,7 @@ "min": 1, "max": 20, "default": 6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kNumberFramesToSaveAfterHit": { "category": "Club Data", @@ -1835,8 +1665,7 @@ "min": 1, "max": 20, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kClubImageWidthPixels": { "category": "Club Data", @@ -1847,8 +1676,7 @@ "min": 100, "max": 1920, "default": 340, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kClubImageHeightPixels": { "category": "Club Data", @@ -1859,8 +1687,7 @@ "min": 100, "max": 1080, "default": 200, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kClubImageCameraGain": { "category": "Club Data", @@ -1871,8 +1698,7 @@ "min": 1, "max": 100, "default": 40, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kClubImageShutterSpeedMultiplier": { "category": "Club Data", @@ -1884,63 +1710,7 @@ "max": 2.0, "step": 0.1, "default": 0.4, - "requiresRestart": false, - "passedVia": "json" - }, - "cli.cam_still_mode": { - "category": "Testing", - "displayName": "Camera Still Mode", - "description": "Take single still picture and exit", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--cam_still_mode", - "internal": true - }, - "cli.pulse_test": { - "category": "Testing", - "displayName": "Pulse Test", - "description": "Continuous strobe/shutter test", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--pulse_test", - "internal": true - }, - "cli.send_test_results": { - "category": "Testing", - "displayName": "Send Test Results", - "description": "Send test IPC message and exit", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--send_test_results", - "internal": true - }, - "cli.lm_comparison_mode": { - "category": "Testing", - "displayName": "LM Comparison Mode", - "description": "Configure for other IR launch monitor environment", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--lm_comparison_mode", - "internal": true - }, - "cli.skip_wait_armed": { - "category": "Testing", - "displayName": "Skip Wait Armed", - "description": "Skip simulator armed state wait", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--skip_wait_armed", - "internal": true + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsMinHoughReturnCircles": { "category": "Ball Detection", @@ -1951,8 +1721,7 @@ "min": 1, "max": 20, "default": 6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsMaxHoughReturnCircles": { "category": "Ball Detection", @@ -1963,8 +1732,7 @@ "min": 5, "max": 50, "default": 20, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsMinParam2": { "category": "Ball Detection", @@ -1975,8 +1743,7 @@ "min": 1, "max": 200, "default": 18, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsMaxParam2": { "category": "Ball Detection", @@ -1987,8 +1754,7 @@ "min": 10, "max": 300, "default": 140, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsStartingParam2": { "category": "Ball Detection", @@ -1999,8 +1765,7 @@ "min": 10, "max": 150, "default": 60, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsParam2Increment": { "category": "Ball Detection", @@ -2011,8 +1776,7 @@ "min": 1, "max": 20, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsCurrentParam1": { "category": "Ball Detection", @@ -2023,8 +1787,7 @@ "min": 50, "max": 300, "default": 120, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsHoughDpParam1": { "category": "Ball Detection", @@ -2036,8 +1799,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsPreCannyBlurSize": { "category": "Ball Detection", @@ -2049,8 +1811,7 @@ "max": 15, "step": 2, "default": 3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsPreHoughBlurSize": { "category": "Ball Detection", @@ -2062,8 +1823,7 @@ "max": 25, "step": 2, "default": 13, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsUseAltHoughAlgorithm": { "category": "Ball Detection", @@ -2072,8 +1832,7 @@ "description": "Use alternative HoughCircles algorithm for strobed balls", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallMinParam2": { "category": "Ball Detection", @@ -2085,8 +1844,7 @@ "max": 2.0, "step": 0.1, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallMaxParam2": { "category": "Ball Detection", @@ -2098,8 +1856,7 @@ "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallStartingParam2": { "category": "Ball Detection", @@ -2111,8 +1868,7 @@ "max": 1.5, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallParam2Increment": { "category": "Ball Detection", @@ -2124,8 +1880,7 @@ "max": 0.1, "step": 0.01, "default": 0.03, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallCurrentParam1": { "category": "Ball Detection", @@ -2136,8 +1891,7 @@ "min": 100, "max": 500, "default": 300, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingMinHoughReturnCircles": { "category": "Ball Detection", @@ -2148,8 +1902,7 @@ "min": 1, "max": 20, "default": 6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingMaxHoughReturnCircles": { "category": "Ball Detection", @@ -2160,8 +1913,7 @@ "min": 10, "max": 50, "default": 25, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingHoughDpParam1": { "category": "Ball Detection", @@ -2173,8 +1925,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingPreHoughBlurSize": { "category": "Ball Detection", @@ -2186,8 +1937,7 @@ "max": 25, "step": 2, "default": 9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.cameras.kCamera1FocalLength": { "category": "Calibration", @@ -2199,8 +1949,7 @@ "max": 10.0, "step": 0.001, "default": 5.868, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2FocalLength": { "category": "Calibration", @@ -2212,8 +1961,7 @@ "max": 10.0, "step": 0.001, "default": 5.511, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_exposure_selection.kNumberHighQualityBallsToRetain": { "category": "Ball Detection", @@ -2224,8 +1972,7 @@ "min": 1, "max": 10, "default": 2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaximumOffTrajectoryDistance": { "category": "Ball Detection", @@ -2236,8 +1983,7 @@ "min": 1, "max": 50, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxDistanceFromTrajectory": { "category": "Ball Detection", @@ -2248,8 +1994,7 @@ "min": 10, "max": 100, "default": 30, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxStrobedBallColorDifferenceRelaxed": { "category": "Ball Detection", @@ -2260,8 +2005,7 @@ "min": 10000, "max": 100000, "default": 70000, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxPuttingBallColorDifferenceRelaxed": { "category": "Ball Detection", @@ -2272,8 +2016,7 @@ "min": 10000, "max": 100000, "default": 40000, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxStrobedBallColorDifferenceStrict": { "category": "Ball Detection", @@ -2284,8 +2027,7 @@ "min": 10000, "max": 100000, "default": 30000, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kUseOnlyHighQualityBallImagesForHLA": { "category": "Ball Detection", @@ -2294,8 +2036,7 @@ "description": "Because earlier-in-time balls have more overlap and lower radius accuracy, this allows for some of those balls to be ignored for HLA calculations.", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kDifferenceM": { "category": "Ball Detection", @@ -2307,8 +2048,7 @@ "max": 2.0, "step": 0.1, "default": 0.6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kDifferenceC": { "category": "Ball Detection", @@ -2320,8 +2060,7 @@ "max": 10.0, "step": 0.5, "default": 3.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kRegionThreshold": { "category": "Ball Detection", @@ -2333,8 +2072,7 @@ "max": 0.2, "step": 0.01, "default": 0.05, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kStrobePulseVectorDriver": { "category": "Strobing", @@ -2352,8 +2090,7 @@ 4.0, 0 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kStrobePulseVectorPutter": { "category": "Strobing", @@ -2369,8 +2106,7 @@ 50.0, 0 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.user_interface.kWebServerResultBallExposureCandidates": { "category": "Display", @@ -2379,8 +2115,7 @@ "description": "Image filename for ball exposure candidates", "type": "text", "default": "ball_exposure_candidates", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerResultSpinBall1Image": { "category": "Display", @@ -2389,8 +2124,7 @@ "description": "Image filename for spin ball 1", "type": "text", "default": "spin_ball_1_gray_image1", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerResultSpinBall2Image": { "category": "Display", @@ -2399,8 +2133,7 @@ "description": "Image filename for spin ball 2", "type": "text", "default": "spin_ball_2_gray_image1", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerResultBallRotatedByBestAngles": { "category": "Display", @@ -2409,8 +2142,7 @@ "description": "Image filename for ball rotated by best angles", "type": "text", "default": "ball1_rotated_by_best_angles", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerCamera2Image": { "category": "Display", @@ -2419,8 +2151,7 @@ "description": "Image filename for camera 2", "type": "text", "default": "log_cam2_last_strobed_img", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerLastTeedBallImage": { "category": "Display", @@ -2429,8 +2160,7 @@ "description": "Image filename for last teed ball", "type": "text", "default": "log_ball_final_found_ball_img", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerErrorExposuresImage": { "category": "Display", @@ -2439,8 +2169,7 @@ "description": "Image filename for error exposures", "type": "text", "default": "log_cam2_last_strobed_img", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerBallSearchAreaImage": { "category": "Display", @@ -2449,8 +2178,7 @@ "description": "Image filename for ball search area", "type": "text", "default": "log_cam1_search_area_img", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltCannyLower": { "category": "Ball Detection", @@ -2461,8 +2189,7 @@ "min": 10, "max": 100, "default": 35, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltCannyUpper": { "category": "Ball Detection", @@ -2473,8 +2200,7 @@ "min": 30, "max": 150, "default": 70, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltCurrentParam1": { "category": "Ball Detection", @@ -2485,8 +2211,7 @@ "min": 50, "max": 300, "default": 130, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltHoughDpParam1": { "category": "Ball Detection", @@ -2498,8 +2223,7 @@ "max": 2.0, "step": 0.1, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltMinParam2": { "category": "Ball Detection", @@ -2511,8 +2235,7 @@ "max": 1.5, "step": 0.05, "default": 0.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltMaxParam2": { "category": "Ball Detection", @@ -2524,8 +2247,7 @@ "max": 2.0, "step": 0.05, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltStartingParam2": { "category": "Ball Detection", @@ -2537,8 +2259,7 @@ "max": 1.0, "step": 0.05, "default": 0.65, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltParam2Increment": { "category": "Ball Detection", @@ -2550,8 +2271,7 @@ "max": 0.2, "step": 0.01, "default": 0.05, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltPreCannyBlurSize": { "category": "Ball Detection", @@ -2563,8 +2283,7 @@ "max": 21, "step": 2, "default": 9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltPreHoughBlurSize": { "category": "Ball Detection", @@ -2576,8 +2295,7 @@ "max": 31, "step": 2, "default": 15, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallCannyLower": { "category": "Ball Detection", @@ -2588,8 +2306,7 @@ "min": 10, "max": 100, "default": 35, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallCannyUpper": { "category": "Ball Detection", @@ -2600,8 +2317,7 @@ "min": 30, "max": 150, "default": 80, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallMinParam2": { "category": "Ball Detection", @@ -2613,8 +2329,7 @@ "max": 1.5, "step": 0.05, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallMaxParam2": { "category": "Ball Detection", @@ -2626,8 +2341,7 @@ "max": 1.5, "step": 0.05, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallStartingParam2": { "category": "Ball Detection", @@ -2639,8 +2353,7 @@ "max": 1.2, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallParam2Increment": { "category": "Ball Detection", @@ -2652,8 +2365,7 @@ "max": 0.1, "step": 0.01, "default": 0.03, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallCurrentParam1": { "category": "Ball Detection", @@ -2664,8 +2376,7 @@ "min": 50, "max": 300, "default": 130, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallHoughDpParam1": { "category": "Ball Detection", @@ -2677,8 +2388,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedPreCannyBlurSize": { "category": "Ball Detection", @@ -2690,8 +2400,7 @@ "max": 21, "step": 2, "default": 11, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedPreHoughBlurSize": { "category": "Ball Detection", @@ -2703,8 +2412,7 @@ "max": 25, "step": 2, "default": 13, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedMinHoughReturnCircles": { "category": "Ball Detection", @@ -2715,8 +2423,7 @@ "min": 1, "max": 10, "default": 1, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedMaxHoughReturnCircles": { "category": "Ball Detection", @@ -2727,8 +2434,7 @@ "min": 1, "max": 10, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallUseLargestBall": { "category": "Ball Detection", @@ -2737,8 +2443,7 @@ "description": "Use the largest detected circle for placed ball", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kUseBestCircleRefinement": { "category": "Ball Detection", @@ -2747,8 +2452,7 @@ "description": "Enable best circle refinement algorithm", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kUseBestCircleLargestCircle": { "category": "Ball Detection", @@ -2757,8 +2461,7 @@ "description": "Use largest circle in best circle algorithm", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleCannyLower": { "category": "Ball Detection", @@ -2769,8 +2472,7 @@ "min": 20, "max": 100, "default": 55, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleCannyUpper": { "category": "Ball Detection", @@ -2781,8 +2483,7 @@ "min": 50, "max": 200, "default": 110, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCirclePreCannyBlurSize": { "category": "Ball Detection", @@ -2794,8 +2495,7 @@ "max": 15, "step": 2, "default": 5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCirclePreHoughBlurSize": { "category": "Ball Detection", @@ -2807,8 +2507,7 @@ "max": 25, "step": 2, "default": 13, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleParam1": { "category": "Ball Detection", @@ -2819,8 +2518,7 @@ "min": 100, "max": 500, "default": 300, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleParam2": { "category": "Ball Detection", @@ -2832,8 +2530,7 @@ "max": 1.0, "step": 0.05, "default": 0.65, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleHoughDpParam1": { "category": "Ball Detection", @@ -2845,8 +2542,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleIdentificationMinRadiusRatio": { "category": "Ball Detection", @@ -2858,8 +2554,7 @@ "max": 1.0, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleIdentificationMaxRadiusRatio": { "category": "Ball Detection", @@ -2871,8 +2566,7 @@ "max": 2.0, "step": 0.05, "default": 1.2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kUseDynamicRadiiAdjustment": { "category": "Ball Detection", @@ -2881,8 +2575,7 @@ "description": "Enable dynamic adjustment of search radii", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kNumberRadiiToAverageForDynamicAdjustment": { "category": "Ball Detection", @@ -2893,8 +2586,7 @@ "min": 1, "max": 10, "default": 2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedNarrowingRadiiMinRatio": { "category": "Ball Detection", @@ -2906,8 +2598,7 @@ "max": 1.0, "step": 0.05, "default": 0.7, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedNarrowingRadiiMaxRatio": { "category": "Ball Detection", @@ -2919,8 +2610,7 @@ "max": 2.0, "step": 0.05, "default": 1.6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedNarrowingRadiiDpParam": { "category": "Ball Detection", @@ -2932,8 +2622,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedNarrowingRadiiParam2": { "category": "Ball Detection", @@ -2945,8 +2634,7 @@ "max": 1.5, "step": 0.05, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingRadiiMinRatio": { "category": "Ball Detection", @@ -2958,8 +2646,7 @@ "max": 1.0, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingRadiiMaxRatio": { "category": "Ball Detection", @@ -2971,8 +2658,7 @@ "max": 1.5, "step": 0.05, "default": 1.1, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingStartingParam2": { "category": "Ball Detection", @@ -2984,8 +2670,7 @@ "max": 1.2, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingParam1": { "category": "Ball Detection", @@ -2996,8 +2681,7 @@ "min": 100, "max": 500, "default": 300, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingRadiiDpParam": { "category": "Ball Detection", @@ -3009,8 +2693,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kBallProximityMarginPercentRelaxed": { "category": "Ball Detection", @@ -3021,8 +2704,7 @@ "min": 30, "max": 100, "default": 65, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kBallProximityMarginPercentStrict": { "category": "Ball Detection", @@ -3033,8 +2715,7 @@ "min": 5, "max": 50, "default": 15, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kClosestBallPairEdgeBackoffPixels": { "category": "Ball Detection", @@ -3045,8 +2726,7 @@ "min": 50, "max": 500, "default": 200, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kColorDifferenceRgbPostMultiplierForDarker": { "category": "Ball Detection", @@ -3058,8 +2738,7 @@ "max": 10.0, "step": 0.5, "default": 4.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kColorDifferenceRgbPostMultiplierForLighter": { "category": "Ball Detection", @@ -3071,8 +2750,7 @@ "max": 5.0, "step": 0.1, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kColorDifferenceStdPostMultiplierForDarker": { "category": "Ball Detection", @@ -3084,8 +2762,7 @@ "max": 10.0, "step": 0.5, "default": 3.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kColorDifferenceStdPostMultiplierForLighter": { "category": "Ball Detection", @@ -3097,8 +2774,7 @@ "max": 10.0, "step": 0.5, "default": 5.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxBallsToRetain": { "category": "Ball Detection", @@ -3109,8 +2785,7 @@ "min": 10, "max": 100, "default": 30, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxIntermediateBallRadiusChangePercent": { "category": "Ball Detection", @@ -3121,8 +2796,7 @@ "min": 1, "max": 20, "default": 5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxPuttingIntermediateBallRadiusChangePercent": { "category": "Ball Detection", @@ -3133,8 +2807,7 @@ "min": 1, "max": 20, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxOverlappedBallRadiusChangeRatio": { "category": "Ball Detection", @@ -3146,8 +2819,7 @@ "max": 2.0, "step": 0.1, "default": 1.3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxRadiusDifferencePercentageFromBest": { "category": "Ball Detection", @@ -3158,8 +2830,7 @@ "min": 10, "max": 100, "default": 35, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kUnlikelyAngleMinimumDistancePixels": { "category": "Ball Detection", @@ -3170,8 +2841,7 @@ "min": 10, "max": 100, "default": 40, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxQualityExposureLaunchAngle": { "category": "Ball Detection", @@ -3182,8 +2852,7 @@ "min": 20, "max": 60, "default": 35, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMinQualityExposureLaunchAngle": { "category": "Ball Detection", @@ -3194,8 +2863,7 @@ "min": -20, "max": 10, "default": -5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxPuttingQualityExposureLaunchAngle": { "category": "Ball Detection", @@ -3206,8 +2874,7 @@ "min": 0, "max": 20, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMinPuttingQualityExposureLaunchAngle": { "category": "Ball Detection", @@ -3218,8 +2885,7 @@ "min": -10, "max": 5, "default": -5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kNumberAngleCheckExposures": { "category": "Ball Detection", @@ -3230,8 +2896,7 @@ "min": 2, "max": 10, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kUsePreImageSubtraction": { "category": "Ball Detection", @@ -3240,8 +2905,7 @@ "description": "Enable pre-image subtraction for better detection", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kPreImageWeightingOverall": { "category": "Ball Detection", @@ -3253,8 +2917,7 @@ "max": 1.0, "step": 0.05, "default": 0.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kPreImageWeightingRed": { "category": "Ball Detection", @@ -3266,8 +2929,7 @@ "max": 2.0, "step": 0.05, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kPreImageWeightingGreen": { "category": "Ball Detection", @@ -3279,8 +2941,7 @@ "max": 2.0, "step": 0.05, "default": 1.2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kPreImageWeightingBlue": { "category": "Ball Detection", @@ -3292,8 +2953,7 @@ "max": 2.0, "step": 0.05, "default": 1.05, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kMaxRegionThreshold": { "category": "Ball Detection", @@ -3305,8 +2965,7 @@ "max": 0.2, "step": 0.01, "default": 0.05, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kFramePeriod": { "category": "Ball Detection", @@ -3317,8 +2976,7 @@ "min": 0, "max": 10, "default": 0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kHSkip": { "category": "Ball Detection", @@ -3329,8 +2987,7 @@ "min": 1, "max": 10, "default": 2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kVSkip": { "category": "Ball Detection", @@ -3341,8 +2998,7 @@ "min": 1, "max": 10, "default": 2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kCroppedImagePixelOffsetLeft": { "category": "Ball Detection", @@ -3353,8 +3009,7 @@ "min": -50, "max": 50, "default": 0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kCroppedImagePixelOffsetUp": { "category": "Ball Detection", @@ -3365,8 +3020,7 @@ "min": -50, "max": 50, "default": -3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.cameras.kCamera1XOffsetForTilt": { "category": "Calibration", @@ -3377,8 +3031,7 @@ "min": -100, "max": 100, "default": 0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1YOffsetForTilt": { "category": "Calibration", @@ -3389,8 +3042,7 @@ "min": -100, "max": 100, "default": 0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2XOffsetForTilt": { "category": "Calibration", @@ -3401,8 +3053,7 @@ "min": -100, "max": 100, "default": 0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2YOffsetForTilt": { "category": "Calibration", @@ -3413,8 +3064,7 @@ "min": -100, "max": 100, "default": 0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kLastPulsePutterRepeats": { "category": "Strobing", @@ -3425,8 +3075,7 @@ "min": 0, "max": 10, "default": 0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kLongerStrobePulseVectorDriver": { "category": "Strobing", @@ -3449,8 +3098,7 @@ 3.5, 0 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kDynamicFollowOnPulseVectorPutter": { "category": "Strobing", @@ -3461,8 +3109,7 @@ "default": [ 444.0 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kNumberPicturesForFocalLengthAverage": { "category": "Calibration", @@ -3473,8 +3120,7 @@ "min": 1, "max": 20, "default": 6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.calibration.kNumberOfCalibrationFailuresToTolerate": { "category": "Calibration", @@ -3485,8 +3131,7 @@ "min": 0, "max": 20, "default": 4, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.user_interface.kRefreshTimeSeconds": { "category": "Network", @@ -3497,8 +3142,7 @@ "min": 1, "max": 30, "default": 3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.testing.kAutomatedTestExpectedResultsCSV": { "type": "string", @@ -3828,8 +3472,7 @@ "3": "Custom rig (user will provide ball positions - see custom parameters below)" }, "default": "1", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kCustomCalibrationRigPositionFromCamera1": { "category": "Calibration", @@ -3842,8 +3485,7 @@ -0.28, 0.44 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kCustomCalibrationRigPositionFromCamera2": { "category": "Calibration", @@ -3856,8 +3498,7 @@ "0.095", "0.435" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam1MetersForStraightOutCamerasV2Enclosure": { "category": "Calibration", @@ -3870,8 +3511,7 @@ "-0.28", "0.44" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam2MetersForStraightOutCamerasV2Enclosure": { "category": "Calibration", @@ -3884,8 +3524,7 @@ "0.095", "0.435" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam1MetersForSkewedCamerasV2Enclosure": { "category": "Calibration", @@ -3898,8 +3537,7 @@ "-0.28", "0.44" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam2MetersForSkewedCamerasV2Enclosure": { "category": "Calibration", @@ -3912,8 +3550,7 @@ "0.095", "0.435" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam1MetersForStraightOutCamerasV3Enclosure": { "category": "Calibration", @@ -3926,8 +3563,7 @@ "-0.243", "0.481" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam2MetersForStraightOutCamerasV3Enclosure": { "category": "Calibration", @@ -3940,8 +3576,7 @@ "0.109", "0.481" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam1MetersForSkewedCamerasV3Enclosure": { "category": "Calibration", @@ -3954,8 +3589,7 @@ "-0.243", "0.481" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam2MetersForSkewedCamerasV3Enclosure": { "category": "Calibration", @@ -3968,8 +3602,7 @@ "0.109", "0.472" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kTestAutoCalibrationFileName": { "type": "string", diff --git a/Software/web-server/tests/test_pitrac_manager.py b/Software/web-server/tests/test_pitrac_manager.py index 59a057fc..38641c2d 100644 --- a/Software/web-server/tests/test_pitrac_manager.py +++ b/Software/web-server/tests/test_pitrac_manager.py @@ -30,8 +30,8 @@ def mock_config_manager(self): config_manager.load_configurations_metadata.return_value = { "cameraDefinitions": { - "camera1": {"displayName": "Camera 1", "slot": "slot1", "defaultIndex": 0, "envPrefix": "PITRAC_SLOT1"}, - "camera2": {"displayName": "Camera 2", "slot": "slot2", "defaultIndex": 1, "envPrefix": "PITRAC_SLOT2"}, + "camera1": {"displayName": "Camera 1", "slot": "slot1", "defaultIndex": 0}, + "camera2": {"displayName": "Camera 2", "slot": "slot2", "defaultIndex": 1}, }, "systemDefaults": { "configStructure": {"systemKey": "system", "camerasKey": "cameras"}, diff --git a/Software/web-server/tests/utils/mock_factories.py b/Software/web-server/tests/utils/mock_factories.py index f5946a5c..99294318 100644 --- a/Software/web-server/tests/utils/mock_factories.py +++ b/Software/web-server/tests/utils/mock_factories.py @@ -54,8 +54,8 @@ def create_pitrac_config_manager() -> Mock: manager.load_configurations_metadata.return_value = { "cameraDefinitions": { - "camera1": {"displayName": "Camera 1", "slot": "slot1", "defaultIndex": 0, "envPrefix": "PITRAC_SLOT1"}, - "camera2": {"displayName": "Camera 2", "slot": "slot2", "defaultIndex": 1, "envPrefix": "PITRAC_SLOT2"}, + "camera1": {"displayName": "Camera 1", "slot": "slot1", "defaultIndex": 0}, + "camera2": {"displayName": "Camera 2", "slot": "slot2", "defaultIndex": 1}, }, "systemDefaults": { "configStructure": {"systemKey": "system", "camerasKey": "cameras"}, diff --git a/Software/web-server/update_configurations.py b/Software/web-server/update_configurations.py deleted file mode 100644 index 81619f98..00000000 --- a/Software/web-server/update_configurations.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to update configurations.json: -1. Add all missing fields from golf_sim_config.json -2. Ensure all fields have proper metadata -""" - -import json -from pathlib import Path - - -def main(): - config_path = Path(__file__).parent / "configurations.json" - - with open(config_path, "r") as f: - data = json.load(f) - - settings = data.get("settings", {}) - - # Count settings that need updating - count_without_passed_via = 0 - count_with_passed_via = 0 - - # Add passedVia: json to all settings without it - for key, value in settings.items(): - if "passedVia" not in value: - value["passedVia"] = "json" - count_without_passed_via += 1 - else: - count_with_passed_via += 1 - - # Add missing CLI arguments that are actually used - missing_cli_args = { - "cli.cam_still_mode": { - "category": "Testing", - "displayName": "Camera Still Mode", - "description": "Take single still picture and exit", - "type": "boolean", - "default": False, - "requiresRestart": True, - "passedVia": "cli", - - "cliArgument": "--cam_still_mode", - "internal": True, - }, - "cli.pulse_test": { - "category": "Testing", - "displayName": "Pulse Test", - "description": "Continuous strobe/shutter test", - "type": "boolean", - "default": False, - "requiresRestart": True, - "passedVia": "cli", - - "cliArgument": "--pulse_test", - "internal": True, - }, - "cli.send_test_results": { - "category": "Testing", - "displayName": "Send Test Results", - "description": "Send test IPC message and exit", - "type": "boolean", - "default": False, - "requiresRestart": True, - "passedVia": "cli", - - "cliArgument": "--send_test_results", - "internal": True, - }, - "cli.lm_comparison_mode": { - "category": "Testing", - "displayName": "LM Comparison Mode", - "description": "Configure for other IR launch monitor environment", - "type": "boolean", - "default": False, - "requiresRestart": True, - "passedVia": "cli", - - "cliArgument": "--lm_comparison_mode", - "internal": True, - }, - "cli.skip_wait_armed": { - "category": "Testing", - "displayName": "Skip Wait Armed", - "description": "Skip simulator armed state wait", - "type": "boolean", - "default": False, - "requiresRestart": True, - "passedVia": "cli", - - "cliArgument": "--skip_wait_armed", - "internal": True, - }, - } - - # Add missing CLI arguments - for key, value in missing_cli_args.items(): - if key not in settings: - settings[key] = value - - # Fix duplicate kWriteSpinAnalysisCsvFiles - search for it - duplicates_found = [] - for key in settings.keys(): - if "kWriteSpinAnalysisCsvFiles" in key: - duplicates_found.append(key) - - # Save updated configuration - data["settings"] = settings - - # Write back with nice formatting - with open(config_path, "w") as f: - json.dump(data, f, indent=2, sort_keys=False) - - print("Updated configurations.json:") - print(f" - Added 'passedVia: json' to {count_without_passed_via} settings") - print(f" - {count_with_passed_via} settings already had passedVia") - print(f" - Added {len(missing_cli_args)} missing CLI arguments") - print(f" - Found {len(duplicates_found)} duplicate keys with kWriteSpinAnalysisCsvFiles") - if duplicates_found: - print(f" Duplicates: {duplicates_found}") - - -if __name__ == "__main__": - main() From 194f9563e838f3c2f36f81b13123917cdcd43001 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 4 Jul 2026 23:49:56 -0400 Subject: [PATCH 043/156] serve testing tool overrides through the config endpoint instead of a file --- Software/web-server/config_manager.py | 59 +++++------------ Software/web-server/pitrac_manager.py | 1 - Software/web-server/testing_tools_manager.py | 66 +++++-------------- .../tests/test_api_endpoints_pitrac.py | 2 - .../tests/test_calibration_manager.py | 16 ----- .../web-server/tests/test_config_manager.py | 13 ++-- .../web-server/tests/test_pitrac_manager.py | 4 +- .../tests/test_testing_tools_manager.py | 60 ++++++++++------- 8 files changed, 81 insertions(+), 140 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index 627317ae..93de3e39 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -18,6 +18,17 @@ logger = logging.getLogger(__name__) +def _deep_merge(base: Dict, override: Dict) -> Dict: + """Recursively merge override into base""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + return result + + class ConfigurationManager: """Manages PiTrac configuration with JSON-based system""" @@ -36,11 +47,11 @@ def expand_path(path_str: str) -> Path: sys_paths.get("userSettingsPath", {}).get("default", "~/.pitrac/config/user_settings.json") ) self.calibration_data_path = expand_path("~/.pitrac/config/calibration_data.json") - self.generated_config_path = self.user_settings_path.parent / "generated_golf_sim_config.json" self.user_settings: Dict[str, Any] = {} self.calibration_data: Dict[str, Any] = {} self.merged_config: Dict[str, Any] = {} + self.transient_overrides: Dict[str, Any] = {} self.restart_required_params = self._load_restart_required_params() @@ -150,22 +161,11 @@ def _build_config_from_metadata(self) -> Dict[str, Any]: # Set the default value current[parts[-1]] = setting_info["default"] - # Helper function for deep merging - def deep_merge(base: Dict, override: Dict) -> Dict: - """Recursively merge override into base""" - result = base.copy() - for key, value in override.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = deep_merge(result[key], value) - else: - result[key] = value - return result - # Apply calibration data (persistent layer) - config = deep_merge(config, self.calibration_data) + config = _deep_merge(config, self.calibration_data) # Then apply user overrides (highest priority) - return deep_merge(config, self.user_settings) + return _deep_merge(config, self.user_settings) def register_callback(self, key_pattern: str, callback: Callable[[str, Any], None]) -> None: """Register callback for configuration updates matching pattern @@ -593,36 +593,7 @@ def build_generated_config(self) -> Dict[str, Any]: if json_settings_count == 0: raise RuntimeError("No JSON settings found to generate config") - return config - - def generate_golf_sim_config(self) -> Path: - """Generate golf_sim_config.json from configurations metadata and user settings - - This method creates a complete golf_sim_config.json file by: - 1. Taking every setting in the configurations metadata - 2. Getting their values (default + user overrides) - 3. Building the nested JSON structure expected by pitrac_lm - - Returns: - Path to the generated configuration file - - Raises: - RuntimeError: If generation fails - """ - try: - config = self.build_generated_config() - - # Save to generated location - generated_path = self.user_settings_path.parent / "generated_golf_sim_config.json" - if not self._save_json(generated_path, config): - raise RuntimeError(f"Failed to save generated config to {generated_path}") - - logger.info(f"Generated golf_sim_config.json with {len(config.get('gs_config', {}))} top-level gs_config keys at {generated_path}") - return generated_path - - except Exception as e: - logger.error(f"Failed to generate golf_sim_config.json: {e}") - raise RuntimeError(f"Config generation failed: {e}") + return _deep_merge(config, self.transient_overrides) def _set_nested_json(self, config: dict, key: str, value: Any): """Set value in nested JSON structure based on dot notation key diff --git a/Software/web-server/pitrac_manager.py b/Software/web-server/pitrac_manager.py index bca75650..57df534a 100644 --- a/Software/web-server/pitrac_manager.py +++ b/Software/web-server/pitrac_manager.py @@ -218,7 +218,6 @@ def get_status(self) -> Dict[str, Any]: "is_running": pid is not None, "pid": pid, "log_file": str(self.log_file), - "generated_config_path": str(Path.home() / ".pitrac/config/generated_golf_sim_config.json"), "binary": self.pitrac_binary, } diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index 8817851c..0571658d 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -12,6 +12,8 @@ from typing import Any, Dict, List, Optional from datetime import datetime +from constants import SERVER_PORT + logger = logging.getLogger(__name__) @@ -140,74 +142,43 @@ async def run_tool(self, tool_id: str) -> Dict[str, Any]: tool_info = self.tools[tool_id] try: - config_path = self.config_manager.generate_golf_sim_config() + testing: Dict[str, str] = {} - # For image test tool, update config with uploaded image if tool_info.get("uses_uploaded_image"): test_images = list(self.test_images_dir.glob("*")) if not test_images: return {"status": "error", "message": "No test images found. Please upload an image first."} - # Use the most recent image latest_image = max(test_images, key=lambda p: p.stat().st_mtime) logger.info(f"Using test image: {latest_image}") - # Modify the generated config to add test image path - import json - - with open(config_path, "r") as f: - config_json = json.load(f) - - # Add test configuration section for SystemMode::kTest - if "gs_config" not in config_json: - config_json["gs_config"] = {} - if "testing" not in config_json["gs_config"]: - config_json["gs_config"]["testing"] = {} - - image_filename = latest_image.name - - # Set the base directory to where our test images are - config_json["gs_config"]["testing"]["kBaseTestImageDir"] = str(self.test_images_dir) + "/" - config_json["gs_config"]["testing"]["kTwoImageTestTeedBallImage"] = image_filename - config_json["gs_config"]["testing"]["kTwoImageTestStrobedBallImage"] = image_filename - config_json["gs_config"]["testing"]["kTwoImageTestPreImage"] = "" # Optional + testing["kBaseTestImageDir"] = str(self.test_images_dir) + "/" + testing["kTwoImageTestTeedBallImage"] = latest_image.name + testing["kTwoImageTestStrobedBallImage"] = latest_image.name + testing["kTwoImageTestPreImage"] = "" - with open(config_path, "w") as f: - json.dump(config_json, f, indent=2) - - # For sample image test or automated testing, set up test suite paths if tool_id in ("test_images", "automated_testing"): - import json - test_suite_dir = Path("/usr/share/pitrac/test-suites/TestSuite_2025_02_07") - with open(config_path, "r") as f: - config_json = json.load(f) - - if "gs_config" not in config_json: - config_json["gs_config"] = {} - if "testing" not in config_json["gs_config"]: - config_json["gs_config"]["testing"] = {} if tool_id == "test_images" and test_suite_dir.exists(): - # Use a matched pair from the test suite (Shot 1) teed_files = sorted(test_suite_dir.glob("*log_ball_final_found_ball_img_Shot_1_*")) strobed_files = sorted(test_suite_dir.glob("*log_cam2_last_strobed_img_Shot_1_*")) if teed_files and strobed_files: - config_json["gs_config"]["testing"]["kBaseTestImageDir"] = str(test_suite_dir) + "/" - config_json["gs_config"]["testing"]["kTwoImageTestTeedBallImage"] = teed_files[0].name - config_json["gs_config"]["testing"]["kTwoImageTestStrobedBallImage"] = strobed_files[0].name + testing["kBaseTestImageDir"] = str(test_suite_dir) + "/" + testing["kTwoImageTestTeedBallImage"] = teed_files[0].name + testing["kTwoImageTestStrobedBallImage"] = strobed_files[0].name if tool_id == "automated_testing": - config_json["gs_config"]["testing"]["kAutomatedTestSuiteDirectory"] = str(test_suite_dir) + "/" - config_json["gs_config"]["testing"]["kAutomatedTestExpectedResultsCSV"] = "Uneekor Comparison 2025-02-07_Small_Test.csv" + testing["kAutomatedTestSuiteDirectory"] = str(test_suite_dir) + "/" + testing["kAutomatedTestExpectedResultsCSV"] = "Uneekor Comparison 2025-02-07_Small_Test.csv" - with open(config_path, "w") as f: - json.dump(config_json, f, indent=2) + # Trace keeps the info-level lines _parse_timing_output reads, whatever the UI level is + self.config_manager.transient_overrides = {"gs_config": {"testing": testing}, "logging": {"level": "trace"}} cmd = [self.pitrac_binary] cmd.extend(tool_info["args"]) - cmd.append(f"--config_file={config_path}") + cmd.append(f"--web_server_port={SERVER_PORT}") config = self.config_manager.get_config() @@ -222,8 +193,6 @@ async def run_tool(self, tool_id: str) -> Dict[str, Any]: base_image_dir = str(Path.home() / "LM_Shares/Images") cmd.append(f"--base_image_logging_dir={base_image_dir}") - cmd.append("--logging_level=trace") - env = os.environ.copy() env["LD_LIBRARY_PATH"] = "/usr/lib/pitrac" env["PITRAC_ROOT"] = "/usr/lib/pitrac" @@ -303,10 +272,11 @@ async def run_tool(self, tool_id: str) -> Dict[str, Any]: "message": f"Tool {tool_id} timed out after {tool_info['timeout']} seconds", } finally: - if tool_id in self.running_processes: - del self.running_processes[tool_id] + self.running_processes.pop(tool_id, None) + self.config_manager.transient_overrides = {} except Exception as e: + self.config_manager.transient_overrides = {} logger.error(f"Error running tool {tool_id}: {e}") return {"status": "error", "message": str(e)} diff --git a/Software/web-server/tests/test_api_endpoints_pitrac.py b/Software/web-server/tests/test_api_endpoints_pitrac.py index e0b0bb33..acbd8d16 100644 --- a/Software/web-server/tests/test_api_endpoints_pitrac.py +++ b/Software/web-server/tests/test_api_endpoints_pitrac.py @@ -58,7 +58,6 @@ def test_pitrac_status(self, client, server_instance): "is_running": True, "pid": 12345, "log_file": "/home/test/.pitrac/logs/pitrac.log", - "generated_config_path": "/home/test/.pitrac/config/generated_golf_sim_config.json", "binary": "/usr/lib/pitrac/pitrac_lm", } ) @@ -76,7 +75,6 @@ def test_pitrac_status_not_running(self, client, server_instance): "is_running": False, "pid": None, "log_file": "/home/test/.pitrac/logs/pitrac.log", - "generated_config_path": "/home/test/.pitrac/config/generated_golf_sim_config.json", "binary": "/usr/lib/pitrac/pitrac_lm", } ) diff --git a/Software/web-server/tests/test_calibration_manager.py b/Software/web-server/tests/test_calibration_manager.py index d49f4d40..ac4ac7db 100644 --- a/Software/web-server/tests/test_calibration_manager.py +++ b/Software/web-server/tests/test_calibration_manager.py @@ -198,7 +198,6 @@ def mock_config_manager(self): "camera2_search_center_y": 500, }, } - mock.generated_config_path = "/tmp/test_config.yaml" mock.get_cli_parameters.return_value = [] # Return empty list for CLI params mock.register_callback = Mock() # Mock callback registration return mock @@ -225,7 +224,6 @@ def test_ball_location_command_dual_mode(self): "camera2_search_center_y": 450, }, } - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -350,7 +348,6 @@ async def test_capture_still_image_success(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -375,7 +372,6 @@ async def test_capture_still_image_file_not_created(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -398,7 +394,6 @@ async def test_capture_still_image_process_error(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -436,7 +431,6 @@ async def test_log_file_creation_during_calibration(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {"calibration": {}} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -481,7 +475,6 @@ async def test_check_ball_location_timeout(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {"calibration": {}} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -505,7 +498,6 @@ async def test_calibration_process_failure(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {"calibration": {}} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -649,7 +641,6 @@ async def test_auto_calibration_success_workflow(self): "camera1_search_center_y": 600, }, } - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" mock_config_manager.reload = Mock() manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -694,7 +685,6 @@ async def test_manual_calibration_success_workflow(self): "camera2_search_center_y": 450, }, } - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" mock_config_manager.reload = Mock() manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -725,7 +715,6 @@ async def test_calibration_failure_no_results(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {"calibration": {}} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -767,7 +756,6 @@ async def test_ball_location_detection_success(self): "camera1_search_center_y": 600, }, } - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -792,7 +780,6 @@ async def test_still_image_capture_with_dual_mode(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -825,7 +812,6 @@ async def test_full_calibration_workflow_success(self): "camera1_search_center_y": 600, }, } - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" mock_config_manager.reload = Mock() manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -873,7 +859,6 @@ async def test_calibration_workflow_with_failures(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {"calibration": {}} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" manager = CalibrationManager(mock_config_manager, "/test/pitrac_lm") @@ -983,7 +968,6 @@ def test_config_reload_after_calibration(self): mock_config_manager.get_cli_parameters = Mock(return_value=[]) mock_config_manager.register_callback = Mock() mock_config_manager.get_config.return_value = {"calibration": {}} - mock_config_manager.generated_config_path = "/tmp/test_config.yaml" mock_config_manager.reload = Mock() manager = CalibrationManager(mock_config_manager) diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index da443455..7b028bb7 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -651,10 +651,15 @@ def test_build_generated_config_returns_merged_dict(self, config_manager): assert isinstance(cfg, dict) assert "gs_config" in cfg - def test_generate_golf_sim_config_matches_builder(self, config_manager): - import json - path = config_manager.generate_golf_sim_config() - assert json.loads(path.read_text()) == config_manager.build_generated_config() + def test_build_generated_config_merges_transient_overrides(self, config_manager): + config_manager.transient_overrides = {"gs_config": {"testing": {"kBaseTestImageDir": "/tmp/x/"}}} + cfg = config_manager.build_generated_config() + assert cfg["gs_config"]["testing"]["kBaseTestImageDir"] == "/tmp/x/" + assert "kGolferOrientation" in cfg["gs_config"]["player"] + + config_manager.transient_overrides = {} + cfg = config_manager.build_generated_config() + assert cfg["gs_config"].get("testing", {}).get("kBaseTestImageDir") != "/tmp/x/" def test_build_generated_config_includes_former_cli_and_env(self, config_manager): cfg = config_manager.build_generated_config() diff --git a/Software/web-server/tests/test_pitrac_manager.py b/Software/web-server/tests/test_pitrac_manager.py index 38641c2d..a6964fe0 100644 --- a/Software/web-server/tests/test_pitrac_manager.py +++ b/Software/web-server/tests/test_pitrac_manager.py @@ -211,9 +211,9 @@ async def test_start_with_exception(self, manager): assert "Test error" in result["message"] def test_command_has_web_server_port_not_config_file(self, manager): - """The binary fetches config over HTTP, so no --config_file is passed.""" + """The binary fetches config over HTTP, so no config file flag is passed.""" cmd = manager._build_command() - assert not any(a.startswith("--config_file") for a in cmd) + assert not any(a.startswith("--config_") for a in cmd) assert f"--web_server_port={SERVER_PORT}" in cmd diff --git a/Software/web-server/tests/test_testing_tools_manager.py b/Software/web-server/tests/test_testing_tools_manager.py index bc708625..dc13b653 100644 --- a/Software/web-server/tests/test_testing_tools_manager.py +++ b/Software/web-server/tests/test_testing_tools_manager.py @@ -3,7 +3,8 @@ import os import time from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, patch, mock_open +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from constants import SERVER_PORT from testing_tools_manager import TestingToolsManager @@ -16,7 +17,7 @@ def mock_config_manager(): "ipc_interface": {"kWebServerShareDirectory": "~/LM_Shares/Images/"} }, } - manager.generate_golf_sim_config.return_value = "/tmp/test_config.json" + manager.transient_overrides = {} return manager @@ -173,33 +174,25 @@ async def test_run_continuous_test_timeout(self, testing_manager, mock_config_ma @pytest.mark.asyncio async def test_run_tool_with_sudo(self, testing_manager, mock_config_manager, tmp_path): """Test running a tool that requires sudo""" - config_file = tmp_path / "test_config.json" - config_file.write_text('{"gs_config": {"testing": {}}}') - mock_config_manager.generate_golf_sim_config.return_value = str(config_file) - mock_process = AsyncMock() mock_process.returncode = 0 mock_process.communicate.return_value = (b"Output", b"") - config_content = '{"gs_config": {}}' - with patch("asyncio.create_subprocess_exec", return_value=mock_process) as mock_exec: - with patch("builtins.open", mock_open(read_data=config_content)): - with patch.object(testing_manager, "_find_and_read_test_log", return_value=None): - await testing_manager.run_tool("test_images") + with patch.object(testing_manager, "_find_and_read_test_log", return_value=None): + await testing_manager.run_tool("test_images") - args, kwargs = mock_exec.call_args - assert args[0] == "sudo" - assert "-E" in args + args, kwargs = mock_exec.call_args + assert args[0] == "sudo" + assert "-E" in args @pytest.mark.asyncio async def test_run_tool_exception(self, testing_manager, mock_config_manager): """Test exception handling during tool run""" - mock_config_manager.generate_golf_sim_config.side_effect = Exception("Config error") - - result = await testing_manager.run_tool("camera1_still") + with patch("asyncio.create_subprocess_exec", side_effect=Exception("Spawn error")): + result = await testing_manager.run_tool("camera1_still") assert result["status"] == "error" - assert "Config error" in result["message"] + assert "Spawn error" in result["message"] @pytest.mark.asyncio async def test_run_tool_with_test_image(self, testing_manager, mock_config_manager, tmp_path): @@ -208,19 +201,40 @@ async def test_run_tool_with_test_image(self, testing_manager, mock_config_manag test_image = testing_manager.test_images_dir / "test_flight.jpg" test_image.touch() - # Mock config file operations - config_content = '{"gs_config": {}}' - mock_process = AsyncMock() mock_process.returncode = 0 mock_process.communicate.return_value = (b"Test completed", b"") with patch("asyncio.create_subprocess_exec", return_value=mock_process): - with patch("builtins.open", mock_open(read_data=config_content)): - result = await testing_manager.run_tool("test_uploaded_image") + result = await testing_manager.run_tool("test_uploaded_image") assert result["status"] == "success" + @pytest.mark.asyncio + async def test_run_tool_sets_transient_overrides_and_clears_them(self, testing_manager, mock_config_manager): + """Test overrides are served while the tool runs and dropped afterwards""" + (testing_manager.test_images_dir / "test_flight.jpg").touch() + seen = {} + + async def communicate(): + seen["overrides"] = mock_config_manager.transient_overrides + return (b"ok", b"") + + mock_process = AsyncMock() + mock_process.returncode = 0 + mock_process.communicate = communicate + + with patch("asyncio.create_subprocess_exec", return_value=mock_process) as mock_exec: + await testing_manager.run_tool("test_uploaded_image") + + testing = seen["overrides"]["gs_config"]["testing"] + assert testing["kTwoImageTestTeedBallImage"] == "test_flight.jpg" + assert seen["overrides"]["logging"] == {"level": "trace"} + cmd = mock_exec.call_args.args + assert f"--web_server_port={SERVER_PORT}" in cmd + assert not any(a.startswith(("--config_", "--logging_level")) for a in cmd) + assert mock_config_manager.transient_overrides == {} + @pytest.mark.asyncio async def test_run_tool_no_test_image(self, testing_manager): """Test running test_uploaded_image with no images""" From 5da3df9c20f5fc0e46220568190cf7bf93d507e2 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 5 Jul 2026 19:05:55 -0400 Subject: [PATCH 044/156] post calibration results over the http client and stop rewriting a config file --- .../ImageProcessing/ImageProcessing.vcxproj | 1 - .../ImageProcessing.vcxproj.filters | 3 - .../ImageProcessing/gs_calibration.cpp | 48 +----- .../ImageProcessing/gs_camera.cpp | 1 - .../ImageProcessing/gs_config.cpp | 94 ------------ .../LMSourceCode/ImageProcessing/gs_config.h | 11 -- .../ImageProcessing/gs_http_client.cpp | 51 +++++++ .../ImageProcessing/gs_http_client.h | 12 +- .../ImageProcessing/gs_web_api.cpp | 137 ------------------ .../LMSourceCode/ImageProcessing/gs_web_api.h | 41 ------ .../LMSourceCode/ImageProcessing/meson.build | 1 - 11 files changed, 67 insertions(+), 333 deletions(-) delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_web_api.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_web_api.h diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj index c83ab061..4b230000 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj @@ -268,7 +268,6 @@ - diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters index 1433ac6f..b0fe4924 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters @@ -258,9 +258,6 @@ Source Files - - Source Files - Source Files diff --git a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp index b817623e..99c30665 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp @@ -17,7 +17,7 @@ #include "gs_camera.h" #include "gs_calibration.h" -#include "gs_web_api.h" +#include "gs_http_client.h" namespace golf_sim { @@ -442,7 +442,7 @@ namespace golf_sim { } average_focal_length /= number_samples; - GS_LOG_MSG(info, "====> Average Focal Length = " + std::to_string(average_focal_length) + ". Will set this value into the gs_config.json file."); + GS_LOG_MSG(info, "====> Average Focal Length = " + std::to_string(average_focal_length) + "."); const double kMinFocalLength = 2.0; const double kMaxFocalLength = 50.0; @@ -479,54 +479,18 @@ namespace golf_sim { return false; } - // Now save the values out to a .json file - std::string camera_number_string = std::to_string(camera_number); std::string focal_length_tag_name = "gs_config.cameras.kCamera" + camera_number_string + "FocalLength"; std::string camera_angles_tag_name = "gs_config.cameras.kCamera" + camera_number_string + "Angles"; - GolfSimConfiguration::SetTreeValue(focal_length_tag_name, average_focal_length); - GolfSimConfiguration::SetTreeValue(camera_angles_tag_name, camera_angles); - - WebApi::UpdateCalibration(focal_length_tag_name, average_focal_length); - +#ifdef __unix__ std::vector angles_vector = {camera_angles[0], camera_angles[1]}; - WebApi::UpdateCalibration(camera_angles_tag_name, angles_vector); - - std::string config_file_name = "golf_sim_config.json"; - - if (!GolfSimOptions::GetCommandLineOptions().config_file_.empty()) { - - config_file_name = GolfSimOptions::GetCommandLineOptions().config_file_; - } - - // Add only to the tail of the file name to ensure that any prefixed path will remain valid - std::string backup_json_file_name = config_file_name + "_BACKUP_" + LoggingTools::GetUniqueLogName() + ".json"; - - GS_LOG_TRACE_MSG(info, "Saving current golf_sim_config.json file to filename = " + backup_json_file_name); - -#ifdef __unix__ - std::string cp_command = "cp " + config_file_name + " " + backup_json_file_name; + return GsHttpClient::UpdateCalibration(focal_length_tag_name, average_focal_length) && + GsHttpClient::UpdateCalibration(camera_angles_tag_name, angles_vector); #else - std::string cp_command = "copy " + config_file_name + " " + backup_json_file_name; + return false; #endif - int command_result = system(cp_command.c_str()); - - if (command_result != 0) { - GS_LOG_TRACE_MSG(trace, "system(cp_command) failed. Could not backup existing golf_sim_config.json file. Exiting"); - return false; - } - - // NOTE - we will overwrite the original config file - std::string results_tree_file_name = config_file_name; - - if (!GolfSimConfiguration::WriteTreeToFile(results_tree_file_name)) { - GS_LOG_MSG(error, "Could not WriteTreeToFile(" + results_tree_file_name + ")."); - return false; - } - - return true; } } diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp index a0ba0084..cdb05379 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp @@ -18,7 +18,6 @@ #include "libcamera_interface.h" #include "gs_camera.h" -#include "gs_web_api.h" namespace golf_sim { diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index c38aa79c..983d0e7b 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -579,98 +579,4 @@ bool GolfSimConfiguration::ReadValues() { } } - bool GolfSimConfiguration::RemoveTreeNode(const std::string& tag_name) { - - try { - std::string end_node_name = "kCamera1FocalLength"; - - if (PropertyExists(tag_name)) { - configuration_root_.erase(tag_name); - return true; - } - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::RemoveTreeNode failed. ERROR: *** " + std::string(e.what()) + " ***"); - return false; - } - - return false; - } - - bool GolfSimConfiguration::SetTreeValue(const std::string& tag_name, const cv::Vec2d& vec) { - - boost::property_tree::ptree values_node; - - try { - // RemoveTreeNode(tag_name); - - bool node_exists = PropertyExists(tag_name); - - if (node_exists) { - int i = 0; - for (boost::property_tree::ptree::value_type& element : configuration_root_.get_child(tag_name)) { - element.second.put("", vec[i++]); - } - } - else { - boost::property_tree::ptree new_node; - boost::property_tree::ptree element; - - element.put("", vec[0]); - new_node.push_back(std::make_pair("", element)); - - element.clear(); - element.put("", vec[1]); - new_node.push_back(std::make_pair("", element)); - - configuration_root_.add_child(tag_name, new_node); - } - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetTreeValue failed. ERROR: *** " + std::string(e.what()) + " ***"); - return false; - } - - // TBD - Set the values - - return true; - } - - bool GolfSimConfiguration::SetTreeValue(const std::string& tag_name, const double value) { - - // RemoveTreeNode(tag_name); - - try { - configuration_root_.put(tag_name, value); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetTreeValue failed. ERROR: *** " + std::string(e.what()) + " ***"); - return false; - } - - // TBD - Set the values - - return true; - } - - bool GolfSimConfiguration::WriteTreeToFile(const std::string& file_name) { - - GS_LOG_MSG(trace, "GolfSimConfiguration::WriteTreeToFile called for file_name = " + file_name); - - std::ofstream file(file_name); - if (file.is_open()) { - boost::property_tree::write_json(file, configuration_root_); - file.close(); - } - else { - GS_LOG_MSG(error, "GolfSimConfiguration::WriteTreeToFile failed. Could not open file for writing."); - return false; - } - - return true; - } - } // namespace golf_sim diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.h b/Software/LMSourceCode/ImageProcessing/gs_config.h index 978c9822..25ced44c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.h +++ b/Software/LMSourceCode/ImageProcessing/gs_config.h @@ -76,17 +76,6 @@ namespace golf_sim { static bool ReadShotInjectionData(std::vector& shots, int& kInterShotInjectionPauseSeconds); - // Set the specified value to the value. The node will be created if necessary - static bool SetTreeValue(const std::string& tag_name, const cv::Vec2d& vec); - static bool SetTreeValue(const std::string& tag_name, const double value); - - // Write the current json tree to the specified file - static bool WriteTreeToFile(const std::string& file_name); - - // Remove the named node in the json tree if it exists. - // Returns true if the node had previously existed, false if not - static bool RemoveTreeNode(const std::string& tag_name); - // Returns the valiue of the environment variable PITRAC_ROOT static std::string GetPiTracRootPath(); diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp index cb7c2815..dd849e1c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp @@ -5,6 +5,9 @@ #ifdef __unix__ +#include +#include + #include "gs_http_client.h" #include "logging_tools.h" #include "httplib.h" @@ -74,6 +77,54 @@ void GsHttpClient::PostImageReady(const std::string& filename) { } } +bool GsHttpClient::PutJson(const std::string& path, const std::string& json_body) { + try { + httplib::Client cli(host_, port_); + cli.set_connection_timeout(2); + cli.set_read_timeout(5); + + auto res = cli.Put(path, json_body, "application/json"); + return res && res->status == 200; + } catch (const std::exception& e) { + GS_LOG_MSG(warning, "HTTP PUT exception: " + std::string(e.what())); + return false; + } +} + +bool GsHttpClient::PutConfigValue(const std::string& key, const std::string& json_value) { + if (!PutJson("/api/config/" + key, "{\"value\": " + json_value + "}")) { + GS_LOG_MSG(error, "Failed to save calibration value " + key + " to the web server."); + return false; + } + GS_LOG_MSG(info, "Saved calibration: " + key + " = " + json_value); + return true; +} + +bool GsHttpClient::UpdateCalibration(const std::string& key, double value) { + return PutConfigValue(key, FormatAsJson(value)); +} + +bool GsHttpClient::UpdateCalibration(const std::string& key, const std::vector& values) { + return PutConfigValue(key, FormatAsJson(values)); +} + +std::string GsHttpClient::FormatAsJson(double value) { + std::stringstream ss; + ss << std::setprecision(10) << value; + return ss.str(); +} + +std::string GsHttpClient::FormatAsJson(const std::vector& values) { + std::stringstream ss; + ss << "["; + for (size_t i = 0; i < values.size(); ++i) { + if (i > 0) ss << ", "; + ss << std::setprecision(10) << values[i]; + } + ss << "]"; + return ss.str(); +} + } // namespace golf_sim #endif diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.h b/Software/LMSourceCode/ImageProcessing/gs_http_client.h index 6aa08f27..9d90571f 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.h +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.h @@ -8,19 +8,27 @@ #ifdef __unix__ #include +#include namespace golf_sim { -// Lightweight HTTP client for posting shot results to the Python web server. -// Fire-and-forget — failures are logged but don't block the shot cycle. +// HTTP client for the Python web server. Shot result posts are fire-and-forget: +// failures are logged but don't block the shot cycle. class GsHttpClient { public: static void Init(const std::string& host = "localhost", int port = 8080); static std::string FetchConfig(); // GET /api/internal/config; returns body or "" on failure static void PostResult(const std::string& json_body); static void PostImageReady(const std::string& filename); + static bool PutJson(const std::string& path, const std::string& json_body); + static bool UpdateCalibration(const std::string& key, double value); + static bool UpdateCalibration(const std::string& key, const std::vector& values); private: + static bool PutConfigValue(const std::string& key, const std::string& json_value); + static std::string FormatAsJson(double value); + static std::string FormatAsJson(const std::vector& values); + static std::string host_; static int port_; }; diff --git a/Software/LMSourceCode/ImageProcessing/gs_web_api.cpp b/Software/LMSourceCode/ImageProcessing/gs_web_api.cpp deleted file mode 100644 index aa5df84b..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_web_api.cpp +++ /dev/null @@ -1,137 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include "gs_web_api.h" -#include "logging_tools.h" -#include -#include -#include - -#ifdef _WIN32 -#define popen _popen -#define pclose _pclose -#endif - - -namespace golf_sim { - -bool WebApi::UpdateCalibration(const std::string& key, double value) { - std::string url = GetWebServerUrl() + "/api/config/" + key; - std::string payload = "{\"value\": " + FormatAsJson(value) + "}"; - std::string response; - - bool success = ExecuteCurl(url, "PUT", payload, response); - - if (success) { - GS_LOG_MSG(info, "Successfully updated calibration: " + key + " = " + std::to_string(value)); - } else { - GS_LOG_MSG(warning, "Failed to update calibration via web API: " + key + - ". Web server may not be running. Calibration saved locally to golf_sim_config.json"); - } - - return success; -} - -bool WebApi::UpdateCalibration(const std::string& key, const std::vector& values) { - std::string url = GetWebServerUrl() + "/api/config/" + key; - std::string payload = "{\"value\": " + FormatAsJson(values) + "}"; - std::string response; - - bool success = ExecuteCurl(url, "PUT", payload, response); - - if (success) { - GS_LOG_MSG(info, "Successfully updated calibration array: " + key); - } else { - GS_LOG_MSG(warning, "Failed to update calibration via web API: " + key + - ". Web server may not be running. Calibration saved locally to golf_sim_config.json"); - } - - return success; -} - -bool WebApi::IsWebServerAvailable() { - std::string url = GetWebServerUrl() + "/health"; - std::string response; - - return ExecuteCurl(url, "GET", "", response); -} - -std::string WebApi::GetWebServerUrl() { - const char* env_url = std::getenv("PITRAC_WEB_SERVER_URL"); - if (env_url != nullptr) { - return std::string(env_url); - } - return kDefaultWebServerUrl; -} -std::string WebApi::ShellEscape(const std::string& input) { - std::stringstream escaped; - - escaped << "'"; - - for (char c : input) { - if (c == '\'') { - escaped << "'\\''"; - } else { - escaped << c; - } - } - - escaped << "'"; - return escaped.str(); -} - -bool WebApi::ExecuteCurl(const std::string& url, const std::string& method, - const std::string& payload, std::string& response) { - std::stringstream cmd; - - // Build curl command with timeout and silent mode - cmd << "curl -s -m 2 -X " << ShellEscape(method); - - if (!payload.empty()) { - cmd << " -H 'Content-Type: application/json'"; - cmd << " -d " << ShellEscape(payload); - - } - cmd << " " << ShellEscape(url) << " 2>/dev/null"; - - // Execute curl command - FILE* pipe = popen(cmd.str().c_str(), "r"); - if (!pipe) { - return false; - } - - // Read response - char buffer[128]; - response.clear(); - while (!feof(pipe)) { - if (fgets(buffer, 128, pipe) != nullptr) { - response += buffer; - } - } - - int exit_code = pclose(pipe); - - // Check if curl succeeded (exit code 0) - return exit_code == 0 && !response.empty(); -} - -std::string WebApi::FormatAsJson(double value) { - std::stringstream ss; - ss << std::setprecision(10) << value; - return ss.str(); -} - -std::string WebApi::FormatAsJson(const std::vector& values) { - std::stringstream ss; - ss << "["; - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) ss << ", "; - ss << std::setprecision(10) << values[i]; - } - ss << "]"; - return ss.str(); -} - -} // namespace golf_sim \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/gs_web_api.h b/Software/LMSourceCode/ImageProcessing/gs_web_api.h deleted file mode 100644 index 8a247077..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_web_api.h +++ /dev/null @@ -1,41 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include -#include - -namespace golf_sim { - -class WebApi { -public: - // Send calibration update to web server - // Returns true if successful, false otherwise - static bool UpdateCalibration(const std::string& key, double value); - static bool UpdateCalibration(const std::string& key, const std::vector& values); - - // Check if web server is available - static bool IsWebServerAvailable(); - -private: - // Get web server URL from environment or use default - static std::string GetWebServerUrl(); - - // Execute curl command and return response - static bool ExecuteCurl(const std::string& url, const std::string& method, - const std::string& payload, std::string& response); - - static std::string ShellEscape(const std::string& input); - - // Format value as JSON - static std::string FormatAsJson(double value); - static std::string FormatAsJson(const std::vector& values); - - // Default web server URL - static constexpr const char* kDefaultWebServerUrl = "http://localhost:8080"; -}; - -} // namespace golf_sim \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index eda8b061..81e2ef42 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -200,7 +200,6 @@ pitrac_lm_sources += ([ 'gs_automated_testing.cpp', 'gs_calibration.cpp', 'gs_camera.cpp', - 'gs_web_api.cpp', 'gs_clubs.cpp', 'gs_club_data.cpp', 'gs_options.cpp', From 5a24e85e60387e22622dcb6af6a78ae6b9874f2d Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 6 Jul 2026 14:41:26 -0400 Subject: [PATCH 045/156] config only ever comes from the web server now --- .../ImageProcessing/configuration_manager.cpp | 79 +++---------------- .../ImageProcessing/configuration_manager.h | 12 ++- .../ImageProcessing/gs_config.cpp | 22 ++---- .../LMSourceCode/ImageProcessing/gs_config.h | 2 +- .../ImageProcessing/gs_options.cpp | 2 - .../LMSourceCode/ImageProcessing/gs_options.h | 3 - .../LMSourceCode/ImageProcessing/lm_main.cpp | 15 ++-- 7 files changed, 27 insertions(+), 108 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp b/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp index 13b1b0f4..8b168869 100644 --- a/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp +++ b/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp @@ -11,27 +11,7 @@ #include #include -// Helper function to merge property trees (user overrides defaults) namespace { - void merge_ptree(const boost::property_tree::ptree& from, boost::property_tree::ptree& to) { - for (const auto& [key, value] : from) { - if (value.empty()) { - // Leaf node - copy value - to.put(key, value.data()); - } else { - // Non-leaf node - recursive merge - auto child = to.get_child_optional(key); - if (child) { - boost::property_tree::ptree merged = *child; - merge_ptree(value, merged); - to.put_child(key, merged); - } else { - to.put_child(key, value); - } - } - } - } - // Helper function to convert YAML to property tree void yaml_to_ptree(const YAML::Node& node, boost::property_tree::ptree& pt, const std::string& key = "") { if (node.IsScalar()) { @@ -62,59 +42,23 @@ namespace { namespace golf_sim { bool ConfigurationManager::Initialize( - const std::string& json_config_file, + const std::string& json_config_body, const std::string& yaml_config_file, - const std::map& cli_overrides, - const std::string& json_config_body) { + const std::map& cli_overrides) { GS_LOG_TRACE_MSG(trace, "Initializing ConfigurationManager"); - json_config_file_ = json_config_file; json_config_body_ = json_config_body; - // Load system defaults: from the supplied body (fetched over HTTP — single read) when given, - // otherwise from the json_config_file on disk. - if (!json_config_body.empty()) { - try { - std::istringstream body_stream(json_config_body); - boost::property_tree::read_json(body_stream, json_config_); - GS_LOG_MSG(info, "Loaded system defaults from web server config body"); - } catch (const boost::property_tree::json_parser_error& e) { - GS_LOG_MSG(error, "Failed to parse system config body: " + std::string(e.what())); - return false; - } - } else if (std::filesystem::exists(json_config_file)) { - try { - boost::property_tree::read_json(json_config_file, json_config_); - GS_LOG_MSG(info, "Loaded system defaults from: " + json_config_file); - } catch (const boost::property_tree::json_parser_error& e) { - GS_LOG_MSG(error, "Failed to parse system config: " + std::string(e.what())); - return false; - } - } else { - GS_LOG_MSG(warning, "System configuration file not found: " + json_config_file); - } - - // Load user settings from user_settings.json (new JSON-only approach) - std::string user_settings_file = std::string(std::getenv("HOME") ? std::getenv("HOME") : "") + "/.pitrac/config/user_settings.json"; - - if (std::filesystem::exists(user_settings_file)) { - try { - boost::property_tree::ptree user_settings; - boost::property_tree::read_json(user_settings_file, user_settings); - - // Merge user settings into json_config (user overrides defaults) - merge_ptree(user_settings, json_config_); - - GS_LOG_MSG(info, "Loaded user settings from: " + user_settings_file); - } catch (const boost::property_tree::json_parser_error& e) { - GS_LOG_MSG(error, "Failed to parse user settings: " + std::string(e.what())); - // Continue with defaults if user settings are corrupt - } - } else { - GS_LOG_MSG(debug, "No user settings found at: " + user_settings_file); + try { + std::istringstream body_stream(json_config_body); + boost::property_tree::read_json(body_stream, json_config_); + GS_LOG_MSG(info, "Loaded system defaults from web server config body"); + } catch (const boost::property_tree::json_parser_error& e) { + GS_LOG_MSG(error, "Failed to parse system config body: " + std::string(e.what())); + return false; } - + // DEPRECATED: Support legacy YAML for migration period only // This will be removed in future versions if (!yaml_config_file.empty() && yaml_config_file != "none") { @@ -322,8 +266,7 @@ bool ConfigurationManager::Reload() { yaml_config_.clear(); // Keep CLI overrides - // Reload — re-parse the cached HTTP body if we have one, else re-read the file - return Initialize(json_config_file_, yaml_config_file_, {}, json_config_body_); + return Initialize(json_config_body_, yaml_config_file_, {}); } bool ConfigurationManager::LoadMappings(const std::string& mappings_file) { diff --git a/Software/LMSourceCode/ImageProcessing/configuration_manager.h b/Software/LMSourceCode/ImageProcessing/configuration_manager.h index 1e463c17..7c3c0bee 100644 --- a/Software/LMSourceCode/ImageProcessing/configuration_manager.h +++ b/Software/LMSourceCode/ImageProcessing/configuration_manager.h @@ -40,16 +40,15 @@ class ConfigurationManager { /** * Initialize the configuration system - * @param json_config_file Path to golf_sim_config.json (defaults) + * @param json_config_body Config JSON served by the web server * @param yaml_config_file Path to pitrac.yaml (user overrides) * @param cli_overrides Command-line overrides * @return true if initialization successful */ bool Initialize( - const std::string& json_config_file = ".pitrac/config/generated_golf_sim_config.json", + const std::string& json_config_body, const std::string& yaml_config_file = "", - const std::map& cli_overrides = {}, - const std::string& json_config_body = "" // when non-empty, parse this instead of reading json_config_file + const std::map& cli_overrides = {} ); /** @@ -165,10 +164,9 @@ class ConfigurationManager { boost::property_tree::ptree mappings_; // Parameter mappings boost::property_tree::ptree presets_; // Configuration presets - // File paths for reloading - std::string json_config_file_; + // Inputs for reloading std::string yaml_config_file_; - std::string json_config_body_; // cached config body (from HTTP) so Reload re-parses it instead of re-reading a file + std::string json_config_body_; // Thread safety mutable std::mutex config_mutex_; diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index 983d0e7b..d4ebe62c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -11,7 +11,6 @@ #include #include #include -#include #include "gs_http_client.h" #include "logging_tools.h" #include "gs_camera.h" @@ -74,26 +73,16 @@ static void PopulateOptionsFromConfig() { } } - bool GolfSimConfiguration::Initialize(const std::string& configuration_filename) { + bool GolfSimConfiguration::Initialize() { - // Obtain the config body ONCE: read the file when a path is explicitly given - // (e.g. the testing tools that inject runtime keys), otherwise fetch it from the - // web server over HTTP. Both ConfigurationManager and configuration_root_ parse - // this same body — a single read, no second file/HTTP fetch. + // ConfigurationManager and configuration_root_ both parse this one body. std::string config_body; - if (!configuration_filename.empty() && std::filesystem::exists(configuration_filename)) { - std::ifstream f(configuration_filename); - std::stringstream ss; - ss << f.rdbuf(); - config_body = ss.str(); - } else { #ifdef __unix__ - config_body = GsHttpClient::FetchConfig(); + config_body = GsHttpClient::FetchConfig(); #endif - } if (config_body.empty()) { - GS_LOG_MSG(error, "GolfSimConfiguration::Initialize failed: no config. The web server must be running to serve /api/internal/config, or pass --config_file= to read from a file."); + GS_LOG_MSG(error, "GolfSimConfiguration::Initialize failed: no config. The web server must be running to serve /api/internal/config."); return false; } @@ -107,9 +96,8 @@ static void PopulateOptionsFromConfig() { return false; } - // Initialize ConfigurationManager (override support) from the SAME body — single read. ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (!config_mgr.Initialize(configuration_filename, "", {}, config_body)) { + if (!config_mgr.Initialize(config_body)) { GS_LOG_MSG(warning, "ConfigurationManager initialization failed, using JSON only"); } else { GS_LOG_MSG(info, "ConfigurationManager initialized with override support"); diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.h b/Software/LMSourceCode/ImageProcessing/gs_config.h index 25ced44c..8eda8405 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.h +++ b/Software/LMSourceCode/ImageProcessing/gs_config.h @@ -44,7 +44,7 @@ namespace golf_sim { kRPiUnknown }; - static bool Initialize(const std::string& configuration_filename = "gs_config.json"); + static bool Initialize(); // Uses a safer version of getenv when in Windows environment. static std::string safe_getenv(const std::string& varname); diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.cpp b/Software/LMSourceCode/ImageProcessing/gs_options.cpp index 933c1f4f..18adcdd1 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_options.cpp @@ -166,8 +166,6 @@ void GolfSimOptions::Print() const std::cout << " e6_host_address: " << e6_host_address_ << std::endl; if (!gspro_host_address_.empty()) std::cout << " gspro_host_address: " << gspro_host_address_ << std::endl; - if (!config_file_.empty()) - std::cout << " configuration file: " << config_file_ << std::endl; std::cout << " web_server_port: " << std::to_string(web_server_port_) << std::endl; std::cout << " pulse_test: " << std::to_string(perform_pulse_test_) << std::endl; std::cout << " golfer_orientation: " << golfer_orientation_string_ << std::endl; diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.h b/Software/LMSourceCode/ImageProcessing/gs_options.h index 933c5420..9f556dbe 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.h +++ b/Software/LMSourceCode/ImageProcessing/gs_options.h @@ -120,8 +120,6 @@ namespace golf_sim { "Specify the name or IP address of the host PC that is running the E6 simulator. Default is: , indicating no TruGolf sim is connected.") ("gspro_host_address", value(&gspro_host_address_)->default_value(""), "Specify the name or IP address of the host PC that is running the GSPro simulator. Default is: , indicating no GSPro sim is connected.") - ("config_file", value(&config_file_)->default_value(""), - "Read configuration from this JSON file instead of fetching it from the web server. Default is empty (fetch over HTTP).") ("web_server_port", value(&web_server_port_)->default_value(8080), "Port of the PiTrac web server (on localhost) to fetch configuration from and post results to.") ("cmd_file,cmd", value(&command_line_file_)->implicit_value("config.txt"), @@ -161,7 +159,6 @@ namespace golf_sim { std::string web_server_share_dir_; std::string e6_host_address_; std::string gspro_host_address_; - std::string config_file_; std::string golfer_orientation_string_; SystemMode system_mode_ = kTest; LoggingLevel logging_level_ = kInfo; diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 111e8fa8..5c66cd3b 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1486,18 +1486,13 @@ int main(int argc, char *argv[]) GolfSimOptions::GetCommandLineOptions().Print(); - // Empty unless --config_file was explicitly given; empty => fetch config over HTTP. - std::string config_file_name = GolfSimOptions::GetCommandLineOptions().config_file_; + GS_LOG_MSG(info, "Loading configuration from the web server over HTTP"); - GS_LOG_MSG(info, config_file_name.empty() - ? "Loading configuration from the web server over HTTP" - : "Loading configuration from file: " + config_file_name); - - // Point the HTTP client at the web server before config load — Initialize fetches config from it. + // Initialize fetches config through the HTTP client, so point it at the web server first. GsHttpClient::Init("localhost", GolfSimOptions::GetCommandLineOptions().web_server_port_); - if (!GolfSimConfiguration::Initialize(config_file_name)) { - GS_LOG_MSG(error, "Could not initialize configuration module using config file: " + config_file_name + ". Exiting."); + if (!GolfSimConfiguration::Initialize()) { + GS_LOG_MSG(error, "Could not initialize configuration module. Exiting."); return 0; } @@ -1555,7 +1550,7 @@ int main(int argc, char *argv[]) GS_LOG_MSG(trace, "PiTrac is using a Version 3 Control Board, checking calibration."); // If the board has been calibrated, there will be a value - // in the user_settings.json file + // in the config int connector_board_dac_setting = -1; GolfSimConfiguration::SetConstant("gs_config.strobing.kDAC_setting", connector_board_dac_setting); From e62ab73ba5d2ea3e50b9fce2279ef7e485d3520b Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 6 Jul 2026 17:11:24 -0400 Subject: [PATCH 046/156] setconstant keeps the default when a key is missing --- .../ImageProcessing/gs_camera.cpp | 40 +---- .../LMSourceCode/ImageProcessing/gs_camera.h | 6 - .../ImageProcessing/gs_config.cpp | 154 +++++------------- .../ImageProcessing/gs_ui_system.cpp | 2 +- .../LMSourceCode/ImageProcessing/lm_main.cpp | 2 +- .../ImageProcessing/pulse_strobe.cpp | 7 - .../ImageProcessing/pulse_strobe.h | 1 - 7 files changed, 48 insertions(+), 164 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp index cdb05379..a59eea02 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp @@ -62,17 +62,12 @@ namespace golf_sim { double GolfSimCamera::kPreImageWeightingRed = 1.0; float GolfSimCamera::kTeedBallSearchAreaMaskRadiusRatio = 5.0f; - double GolfSimCamera::kCamera1CalibrationDistanceToBall = 0.5; - double GolfSimCamera::kCamera2CalibrationDistanceToBall = 0.5; double GolfSimCamera::kCamera1XOffsetForTilt = 0.0; double GolfSimCamera::kCamera1YOffsetForTilt = 0.0; double GolfSimCamera::kCamera2XOffsetForTilt = 0.0; double GolfSimCamera::kCamera2YOffsetForTilt = 0.0; - double GolfSimCamera::kExpectedBallPositionXcm = -50.0; - double GolfSimCamera::kExpectedBallPositionYcm = -28.0; - double GolfSimCamera::kExpectedBallPositionZcm = 50.0; double GolfSimCamera::kExpectedBallRadiusPixelsAt40cm = 50; float GolfSimCamera::kMaxMovedBallRadiusRatio = 1.40f; float GolfSimCamera::kMinMovedBallRadiusRatio = 0.50f; @@ -171,9 +166,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.ball_exposure_selection.kMaxOverlappedBallRadiusChangeRatio", kMaxOverlappedBallRadiusChangeRatio); GolfSimConfiguration::SetConstant("gs_config.ball_exposure_selection.kMaxRadiusDifferencePercentageFromBest", kMaxRadiusDifferencePercentageFromBest); - GolfSimConfiguration::SetConstant("gs_config.calibration.kCamera1CalibrationDistanceToBall", kCamera1CalibrationDistanceToBall); - GolfSimConfiguration::SetConstant("gs_config.calibration.kCamera2CalibrationDistanceToBall", kCamera2CalibrationDistanceToBall); - GolfSimConfiguration::SetConstant("gs_config.ball_position.kTeedBallSearchAreaMaskRadiusRatio", kTeedBallSearchAreaMaskRadiusRatio); GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera1XOffsetForTilt", kCamera1XOffsetForTilt); @@ -181,9 +173,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera2XOffsetForTilt", kCamera2XOffsetForTilt); GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera2YOffsetForTilt", kCamera2YOffsetForTilt); - GolfSimConfiguration::SetConstant("gs_config.ball_position.kExpectedBallPositionXcm", kExpectedBallPositionXcm); - GolfSimConfiguration::SetConstant("gs_config.ball_position.kExpectedBallPositionYcm", kExpectedBallPositionYcm); - GolfSimConfiguration::SetConstant("gs_config.ball_position.kExpectedBallPositionZcm", kExpectedBallPositionZcm); GolfSimConfiguration::SetConstant("gs_config.ball_position.kExpectedBallRadiusPixelsAt40cm", kExpectedBallRadiusPixelsAt40cm); GolfSimConfiguration::SetConstant("gs_config.ball_position.kMaxMovedBallRadiusRatio", kMaxMovedBallRadiusRatio); GolfSimConfiguration::SetConstant("gs_config.ball_position.kMinMovedBallRadiusRatio", kMinMovedBallRadiusRatio); @@ -444,25 +433,6 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "GetCalibratedBall called for camera2 (usually used for camera1 images)."); } - // If we are calibrating the focal length, then override the distance to that specified in - // the JSON file - if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1Calibrate || - GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2Calibrate || - GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1BallLocation || - GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2BallLocation || - GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1TestStandalone) { - - if (kCamera1CalibrationDistanceToBall > 0.01) { - if (GolfSimOptions::GetCommandLineOptions().GetCameraNumber() == GsCameraNumber::kGsCamera1) { - expected_distance = kCamera1CalibrationDistanceToBall; - } - else { - expected_distance = kCamera2CalibrationDistanceToBall; - } - GS_LOG_TRACE_MSG(trace, "GetCalibratedBall overriding expected_distance. Setting to: " + std::to_string(expected_distance)); - } - } - GS_LOG_TRACE_MSG(trace, "GetCalibratedBall using expected ball distance of: " + std::to_string(expected_distance)); double expectedRadius = GetExpectedBallRadiusPixels(camera.camera_hardware_, rgbImg.cols, expected_distance); @@ -576,12 +546,10 @@ namespace golf_sim { if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1Calibrate || GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2Calibrate) { - if (GolfSimOptions::GetCommandLineOptions().GetCameraNumber() == GsCameraNumber::kGsCamera1) { - distance_direct_to_ball = kCamera1CalibrationDistanceToBall; - } - else { - distance_direct_to_ball = kCamera2CalibrationDistanceToBall; - } + distance_direct_to_ball = CvUtils::GetDistance( + GolfSimOptions::GetCommandLineOptions().GetCameraNumber() == GsCameraNumber::kGsCamera1 + ? kCamera1PositionsFromExpectedBallMeters + : kCamera2PositionsFromExpectedBallMeters); b.distance_to_z_plane_from_lens_ = distance_direct_to_ball; diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.h b/Software/LMSourceCode/ImageProcessing/gs_camera.h index 80a3b38b..0fb7ec2b 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.h +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.h @@ -129,17 +129,11 @@ namespace golf_sim { static double kMaxOverlappedBallRadiusChangeRatio; static double kMaxRadiusDifferencePercentageFromBest; - static double kCamera1CalibrationDistanceToBall; - static double kCamera2CalibrationDistanceToBall; - static double kCamera1XOffsetForTilt; static double kCamera1YOffsetForTilt; static double kCamera2XOffsetForTilt; static double kCamera2YOffsetForTilt; - static double kExpectedBallPositionXcm; - static double kExpectedBallPositionYcm; - static double kExpectedBallPositionZcm; static double kExpectedBallRadiusPixelsAt40cm; static double kMinRadiusRatio; static double kMaxRadiusRatio; diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index d4ebe62c..aac7dfbf 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -37,13 +37,6 @@ static std::string GetConfigString(const std::string& tag_name) { return value; } -template -static void SetIfPresent(const std::string& tag_name, T& value) { - if (GolfSimConfiguration::PropertyExists(tag_name)) { - GolfSimConfiguration::SetConstant(tag_name, value); - } -} - static void PopulateOptionsFromConfig() { GolfSimOptions& options = GolfSimOptions::GetCommandLineOptions(); @@ -57,11 +50,11 @@ static void PopulateOptionsFromConfig() { apply_enum("logging.level", &GolfSimOptions::SetLoggingLevel); apply_enum("gs_config.logging.kArtifactSaveLevel", &GolfSimOptions::SetArtifactSaveLevel); - SetIfPresent("gs_config.debug.kShowDebugImages", options.show_images_); - SetIfPresent("gs_config.debug.kWaitForKeyOnImages", options.wait_for_key_on_images_); - SetIfPresent("gs_config.player.kUsePracticeBalls", options.practice_ball_); - SetIfPresent("gs_config.cameras.kCamera1SearchCenterX", options.search_center_x_); - SetIfPresent("gs_config.cameras.kCamera1SearchCenterY", options.search_center_y_); + GolfSimConfiguration::SetConstant("gs_config.debug.kShowDebugImages", options.show_images_); + GolfSimConfiguration::SetConstant("gs_config.debug.kWaitForKeyOnImages", options.wait_for_key_on_images_); + GolfSimConfiguration::SetConstant("gs_config.player.kUsePracticeBalls", options.practice_ball_); + GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera1SearchCenterX", options.search_center_x_); + GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera1SearchCenterY", options.search_center_y_); std::string gspro_address = GetConfigString("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress"); if (!gspro_address.empty()) { @@ -327,135 +320,72 @@ bool GolfSimConfiguration::ReadValues() { void GolfSimConfiguration::SetConstant(const std::string& tag_name, bool& constant_value) { - // Try ConfigurationManager first for override support ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); if (config_mgr.HasKey(tag_name)) { - bool val = config_mgr.GetBool(tag_name, constant_value); - if (val != constant_value) { - GS_LOG_TRACE_MSG(trace, "Override from ConfigurationManager: " + tag_name + " = " + (val ? "true" : "false")); - constant_value = val; - return; - } - } - - // Fall back to original JSON behavior - try { - constant_value = configuration_root_.get(tag_name, false); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; + constant_value = config_mgr.GetBool(tag_name, constant_value); + } else if (auto v = configuration_root_.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); } } void GolfSimConfiguration::SetConstant(const std::string& tag_name, int& constant_value) { - // Try ConfigurationManager first for override support ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); if (config_mgr.HasKey(tag_name)) { - int val = config_mgr.GetInt(tag_name, constant_value); - if (val != constant_value) { - GS_LOG_TRACE_MSG(trace, "Override from ConfigurationManager: " + tag_name + " = " + std::to_string(val)); - constant_value = val; - return; - } - } - - // Fall back to original JSON behavior - try { - constant_value = configuration_root_.get(tag_name, 0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; + constant_value = config_mgr.GetInt(tag_name, constant_value); + } else if (auto v = configuration_root_.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); } } void GolfSimConfiguration::SetConstant(const std::string& tag_name, long& constant_value) { - try { - constant_value = configuration_root_.get(tag_name, 0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; + if (auto v = configuration_root_.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); } } void GolfSimConfiguration::SetConstant(const std::string& tag_name, unsigned int& constant_value) { - try { - constant_value = configuration_root_.get(tag_name, 0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; + if (auto v = configuration_root_.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); } } - void GolfSimConfiguration::SetConstant(const std::string& tag_name, float& constant_value) { - // Try ConfigurationManager first for override support + void GolfSimConfiguration::SetConstant(const std::string& tag_name, float& constant_value) { ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); if (config_mgr.HasKey(tag_name)) { - float val = config_mgr.GetFloat(tag_name, constant_value); - if (val != constant_value) { - GS_LOG_TRACE_MSG(trace, "Override from ConfigurationManager: " + tag_name + " = " + std::to_string(val)); - constant_value = val; - return; - } - } - - // Fall back to original JSON behavior - try { - constant_value = configuration_root_.get(tag_name, 0.0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; + constant_value = config_mgr.GetFloat(tag_name, constant_value); + } else if (auto v = configuration_root_.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); } } - void GolfSimConfiguration::SetConstant(const std::string& tag_name, double& constant_value) { - try { - constant_value = configuration_root_.get(tag_name, 0.0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; + void GolfSimConfiguration::SetConstant(const std::string& tag_name, double& constant_value) { + if (auto v = configuration_root_.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); } } - void GolfSimConfiguration::SetConstant(const std::string& tag_name, std::string& constant_value) { - // Try ConfigurationManager first for override support + void GolfSimConfiguration::SetConstant(const std::string& tag_name, std::string& constant_value) { ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - - // First check if there's a mapped YAML key - std::string yaml_key = tag_name; - // Convert JSON path to potential YAML key (simplified mapping) - // e.g., "gs_config.cameras.kCamera1Gain" -> "cameras.camera1_gain" - - if (config_mgr.HasKey(yaml_key)) { - std::string val = config_mgr.GetString(yaml_key, constant_value); - if (val != constant_value) { - GS_LOG_TRACE_MSG(trace, "Override from ConfigurationManager: " + tag_name + " = " + val); - constant_value = val; - return; - } + if (config_mgr.HasKey(tag_name)) { + constant_value = config_mgr.GetString(tag_name, constant_value); + } else if (auto v = configuration_root_.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); } - - // Fall back to original JSON behavior - try { - constant_value = configuration_root_.get(tag_name, constant_value); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = ""; - } - } + } void GolfSimConfiguration::SetConstant(const std::string& tag_name, cv::Vec3d& vec) { try { diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index ade54ff7..3b6cd07c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -134,7 +134,7 @@ namespace golf_sim { case GsIPCResultType::kWaitingForBallToAppear: if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1Calibrate || GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2Calibrate) { - msg = "Waiting for ball to be teed up at " + std::to_string(GolfSimCamera::kCamera1CalibrationDistanceToBall) + "cm in order to perform calibration."; + msg = "Waiting for ball to be teed up in order to perform calibration."; } else { msg = "Waiting for ball to be teed up."; } diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 5c66cd3b..f535adfd 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1181,7 +1181,7 @@ void run_main(int argc, char* argv[]) // In addition to checking for the ball, this method will send results // to the web server if we are in calibration mode. - GS_LOG_MSG(info, "Calibration Results (Distance of kCamera (1 OR 2) CalibrationDistanceToBall):"); + GS_LOG_MSG(info, "Calibration Results (distance from kCamera (1 OR 2) PositionsFromExpectedBallMeters):"); double average_focal_length = 0.0; const int number_attempts = 20; int number_samples = 0; diff --git a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp index e340fde8..bb22d7c7 100644 --- a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp +++ b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp @@ -67,7 +67,6 @@ namespace golf_sim { long PulseStrobe::kCam2SetupPeriodMilliseconds = 2000; int PulseStrobe::kNumberPrimingPulses = 12; int PulseStrobe::kPrimingPulseFPS = 15; - long PulseStrobe::kPauseBeforeReadyForTriggerMicroSeconds = 100; int PulseStrobe::kPauseToSetUpInnoMakerExternalTriggerMilliseconds = 1000; int PulseStrobe::kPauseBeforeReadyForFinalPrimingPulseMs = 100; @@ -437,7 +436,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.strobing.kCam2SetupPeriodMilliseconds", kCam2SetupPeriodMilliseconds); GolfSimConfiguration::SetConstant("gs_config.strobing.kNumberPrimingPulses", kNumberPrimingPulses); GolfSimConfiguration::SetConstant("gs_config.strobing.kPrimingPulseFPS", kPrimingPulseFPS); - GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeReadyForTriggerMicroSeconds", kPauseBeforeReadyForTriggerMicroSeconds); GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseToSetUpInnoMakerExternalTriggerMilliseconds", kPauseToSetUpInnoMakerExternalTriggerMilliseconds); GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeReadyForFinalPrimingPulseMs", kPauseBeforeReadyForFinalPrimingPulseMs); @@ -506,11 +504,6 @@ namespace golf_sim { lgGpioWrite(lggpio_chip_handle_, kPulseTriggerOutputPin, kOFF); } - // The cnclosure version will affect where the ball on the auto-calibration target is located relative to the cameras - int kEnclosureVersionIntValue = 0; - GolfSimConfiguration::SetConstant("gs_config.strobing.kEnclosureVersion", kEnclosureVersionIntValue); - GolfSimConfiguration::EnclosureType knclosureVersion = (GolfSimConfiguration::EnclosureType)kEnclosureVersionIntValue; - if (callback_function != nullptr) { /* TBD gpioSetSignalFunc(SIGUSR1, callback_function); diff --git a/Software/LMSourceCode/ImageProcessing/pulse_strobe.h b/Software/LMSourceCode/ImageProcessing/pulse_strobe.h index 3d36d1ac..3a682cde 100644 --- a/Software/LMSourceCode/ImageProcessing/pulse_strobe.h +++ b/Software/LMSourceCode/ImageProcessing/pulse_strobe.h @@ -29,7 +29,6 @@ namespace golf_sim { static long kCam2SetupPeriodMilliseconds; static int kNumberPrimingPulses; static int kPrimingPulseFPS; - static long kPauseBeforeReadyForTriggerMicroSeconds; static int kPauseToSetUpInnoMakerExternalTriggerMilliseconds; static int kPauseBeforeReadyForFinalPrimingPulseMs; From b7236566cd5b0afa37f8b3425402789d627149c5 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 6 Jul 2026 20:12:09 -0400 Subject: [PATCH 047/156] keep settings and calibration in sqlite and import the json files --- Software/web-server/config_manager.py | 252 ++++++++---------- Software/web-server/db/database.py | 8 +- .../web-server/db/migrations/002_settings.sql | 12 + Software/web-server/db/repositories.py | 22 ++ Software/web-server/server.py | 4 +- Software/web-server/tests/conftest.py | 5 + .../web-server/tests/test_api_endpoints.py | 8 + .../web-server/tests/test_config_manager.py | 170 ++++++------ Software/web-server/tests/test_database.py | 17 +- .../web-server/tests/test_repositories.py | 16 +- 10 files changed, 295 insertions(+), 219 deletions(-) create mode 100644 Software/web-server/db/migrations/002_settings.sql diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index 93de3e39..c426c66b 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -1,13 +1,15 @@ """Configuration Manager for PiTrac Web Server -Handles reading and writing JSON configuration files with a three-tier system: +Builds configuration from a three-tier system: 1. Generated defaults: From configurations.json metadata -2. Calibration data: ~/.pitrac/config/calibration_data.json (preserved across regenerations) -3. User overrides: ~/.pitrac/config/user_settings.json (read-write, sparse) +2. Calibration data: calibration table in the SQLite database +3. User overrides: settings table in the SQLite database (sparse) + +~/.pitrac/config/user_settings.json and calibration_data.json are merged into +those tables on load and renamed to *.imported. """ import copy -import fcntl import json import logging import os @@ -15,6 +17,10 @@ from threading import RLock from typing import Any, Callable, Dict, List, Optional, Tuple +from constants import DB_PATH +from db.database import Database +from db.repositories import KeyValueRepository + logger = logging.getLogger(__name__) @@ -29,10 +35,44 @@ def _deep_merge(base: Dict, override: Dict) -> Dict: return result +def _set_in_dict(d: Dict[str, Any], key: str, value: Any) -> bool: + """Set value in nested dictionary using dot notation""" + parts = key.split(".") + current = d + + for part in parts[:-1]: + if part not in current: + current[part] = {} + elif not isinstance(current[part], dict): + return False + current = current[part] + + current[parts[-1]] = value + return True + + +def _flatten(nested: Dict[str, Any], prefix: str = "") -> Dict[str, Any]: + flat = {} + for key, value in nested.items(): + full_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + flat.update(_flatten(value, full_key)) + else: + flat[full_key] = value + return flat + + +def _unflatten(flat: Dict[str, Any]) -> Dict[str, Any]: + nested: Dict[str, Any] = {} + for key, value in flat.items(): + _set_in_dict(nested, key, value) + return nested + + class ConfigurationManager: - """Manages PiTrac configuration with JSON-based system""" + """Manages PiTrac configuration stored in SQLite""" - def __init__(self): + def __init__(self, db: Optional[Database] = None): self._lock = RLock() self._metadata_cache = None @@ -42,7 +82,7 @@ def __init__(self): def expand_path(path_str: str) -> Path: return Path(path_str).expanduser() - # Configuration paths for three-tier system + # Legacy JSON files, read only to import them into the database self.user_settings_path = expand_path( sys_paths.get("userSettingsPath", {}).get("default", "~/.pitrac/config/user_settings.json") ) @@ -57,6 +97,10 @@ def expand_path(path_str: str) -> Path: self._config_callbacks: Dict[str, List[Callable[[str, Any], None]]] = {} + db = db or Database(DB_PATH) + self._settings = KeyValueRepository(db, "settings") + self._calibration = KeyValueRepository(db, "calibration") + self.reload() def _load_raw_metadata(self) -> Dict[str, Any]: @@ -86,8 +130,10 @@ def reload(self) -> None: """Reload configuration from metadata, calibration data, and user settings""" with self._lock: self._metadata_cache = None - self.user_settings = self._load_json(self.user_settings_path) - self.calibration_data = self._load_json(self.calibration_data_path) + self._import_json(self._settings, self.user_settings_path) + self._import_json(self._calibration, self.calibration_data_path) + self.user_settings = _unflatten(self._settings.load()) + self.calibration_data = _unflatten(self._calibration.load()) # Build merged config from metadata defaults + calibration + user overrides self.merged_config = self._build_config_from_metadata() self.restart_required_params = self._load_restart_required_params() @@ -101,44 +147,31 @@ def _rebuild_merged_config(self) -> None: self.merged_config = self._build_config_from_metadata() self.restart_required_params = self._load_restart_required_params() - def _load_json(self, path: Path) -> Dict[str, Any]: - """Load JSON file safely""" - if not path.exists(): - return {} - + def _load_json(self, path: Path) -> Optional[Dict[str, Any]]: + """Load a JSON object, or None if the file is unreadable or holds something else""" try: with open(path, "r") as f: - return json.load(f) - except (json.JSONDecodeError, IOError) as e: + data = json.load(f) + except (OSError, ValueError) as e: logger.error(f"Failed to load {path}: {e}") - return {} - - def _save_json(self, path: Path, data: Dict[str, Any]) -> bool: - """Save JSON file with proper formatting and file locking""" - try: - path.parent.mkdir(parents=True, exist_ok=True) + return None + return data if isinstance(data, dict) else None - data_copy = copy.deepcopy(data) + def _import_json(self, repo: KeyValueRepository, path: Path) -> None: + """Merge a legacy JSON file over the table rows (file keys win), then rename it""" + if not path.exists(): + return - lock_path = path.with_suffix(".lock") - temp_path = path.with_suffix(".tmp") - with open(lock_path, "w") as lock_f: - fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) - try: - with open(temp_path, "w") as f: - json.dump(data_copy, f, indent=2, sort_keys=True) - f.flush() - os.fsync(f.fileno()) - temp_path.replace(path) - finally: - fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) - - logger.info(f"Saved configuration to {path}") - return True + data = self._load_json(path) + if data is None: + logger.warning(f"Not importing {path}: it is not a readable JSON object, keeping the stored values") + return - except (IOError, OSError) as e: - logger.error(f"Failed to save {path}: {e}") - return False + # configure-cameras.sh writes dotted keys at the top level, so normalize before merging + merged = _deep_merge(_unflatten(repo.load()), _unflatten(_flatten(data))) + repo.replace_all(_flatten(merged)) + path.rename(path.with_name(path.name + ".imported")) + logger.info(f"Imported {path} into the {repo.table} table") def _build_config_from_metadata(self) -> Dict[str, Any]: """Build configuration from metadata defaults, calibration data, and user overrides""" @@ -319,56 +352,52 @@ def set_config(self, key: str, value: Any) -> Tuple[bool, str, bool]: if is_calibration: calibration_copy = copy.deepcopy(self.calibration_data) if self._delete_from_dict(calibration_copy, key): - if self._save_json(self.calibration_data_path, calibration_copy): - self.calibration_data = calibration_copy - self._rebuild_merged_config() - notify_key = key - notify_value = default_value - result = ( - True, - f"Reset calibration {key} to default value", - key in self.restart_required_params, - ) + self._calibration.replace_all(_flatten(calibration_copy)) + self.calibration_data = calibration_copy + self._rebuild_merged_config() + notify_key = key + notify_value = default_value + result = ( + True, + f"Reset calibration {key} to default value", + key in self.restart_required_params, + ) else: settings_copy = copy.deepcopy(self.user_settings) if self._delete_from_dict(settings_copy, key): - if self._save_json(self.user_settings_path, settings_copy): - self.user_settings = settings_copy - self._rebuild_merged_config() - notify_key = key - notify_value = default_value - result = ( - True, - f"Reset {key} to default value", - key in self.restart_required_params, - ) + self._settings.replace_all(_flatten(settings_copy)) + self.user_settings = settings_copy + self._rebuild_merged_config() + notify_key = key + notify_value = default_value + result = ( + True, + f"Reset {key} to default value", + key in self.restart_required_params, + ) if result is None: result = (True, "Value already at default", False) elif is_calibration: calibration_copy = copy.deepcopy(self.calibration_data) - if self._set_in_dict(calibration_copy, key, value): - if self._save_json(self.calibration_data_path, calibration_copy): - self.calibration_data = calibration_copy - self._rebuild_merged_config() - notify_key = key - notify_value = value - requires_restart = key in self.restart_required_params - result = (True, f"Set calibration {key} = {value}", requires_restart) - else: - result = (False, "Failed to save calibration data", False) + if _set_in_dict(calibration_copy, key, value): + self._calibration.replace_all(_flatten(calibration_copy)) + self.calibration_data = calibration_copy + self._rebuild_merged_config() + notify_key = key + notify_value = value + requires_restart = key in self.restart_required_params + result = (True, f"Set calibration {key} = {value}", requires_restart) else: settings_copy = copy.deepcopy(self.user_settings) - if self._set_in_dict(settings_copy, key, value): - if self._save_json(self.user_settings_path, settings_copy): - self.user_settings = settings_copy - self._rebuild_merged_config() - notify_key = key - notify_value = value - requires_restart = key in self.restart_required_params - result = (True, f"Set {key} = {value}", requires_restart) - else: - result = (False, "Failed to save configuration", False) + if _set_in_dict(settings_copy, key, value): + self._settings.replace_all(_flatten(settings_copy)) + self.user_settings = settings_copy + self._rebuild_merged_config() + notify_key = key + notify_value = value + requires_restart = key in self.restart_required_params + result = (True, f"Set {key} = {value}", requires_restart) if result is None: result = (False, "Failed to set value", False) @@ -390,12 +419,10 @@ def set_calibration_batch(self, updates: Dict[str, Any]) -> Tuple[bool, str]: calibration_copy = copy.deepcopy(self.calibration_data) for key, value in updates.items(): - if not self._set_in_dict(calibration_copy, key, value): + if not _set_in_dict(calibration_copy, key, value): return False, f"Failed to set {key}" - if not self._save_json(self.calibration_data_path, calibration_copy): - return False, "Failed to save calibration data" - + self._calibration.replace_all(_flatten(calibration_copy)) self.calibration_data = calibration_copy self._rebuild_merged_config() @@ -404,21 +431,6 @@ def set_calibration_batch(self, updates: Dict[str, Any]) -> Tuple[bool, str]: return True, f"Saved {len(updates)} calibration values" - def _set_in_dict(self, d: Dict[str, Any], key: str, value: Any) -> bool: - """Set value in nested dictionary using dot notation""" - parts = key.split(".") - current = d - - for part in parts[:-1]: - if part not in current: - current[part] = {} - elif not isinstance(current[part], dict): - return False - current = current[part] - - current[parts[-1]] = value - return True - def _delete_from_dict(self, d: Dict[str, Any], key: str) -> bool: """Delete value from nested dictionary using dot notation""" parts = key.split(".") @@ -464,12 +476,10 @@ def _cleanup_empty_dicts(self, d: Dict[str, Any], max_depth: int = 100, current_ def reset_all(self) -> Tuple[bool, str]: """Reset all user settings to defaults""" with self._lock: - if self._save_json(self.user_settings_path, {}): - self.user_settings = {} - self._rebuild_merged_config() - return True, "Reset all settings to defaults" - - return False, "Failed to reset configuration" + self._settings.replace_all({}) + self.user_settings = {} + self._rebuild_merged_config() + return True, "Reset all settings to defaults" def get_diff(self) -> Dict[str, Any]: """Get differences between user settings and defaults @@ -688,17 +698,6 @@ def load_configurations_metadata(self): logger.error(f"Error loading configurations.json: {e}") return {"settings": {}} - def flatten_config(self, config: Dict[str, Any], prefix: str = "") -> Dict[str, Any]: - """Flatten nested config dict into dot-notation keys.""" - result = {} - for key, value in config.items(): - full_key = f"{prefix}.{key}" if prefix else key - if isinstance(value, dict): - result.update(self.flatten_config(value, full_key)) - else: - result[full_key] = value - return result - def get_categories(self) -> Dict[str, Dict[str, List[str]]]: """Get configuration organized by categories with basic/advanced subcategories @@ -820,26 +819,11 @@ def import_config(self, import_data: Dict[str, Any]) -> Tuple[bool, str]: if isinstance(val, dict): new_cal = copy.deepcopy(val) - # Snapshot originals for rollback - orig_user = copy.deepcopy(self.user_settings) - orig_cal = copy.deepcopy(self.calibration_data) - - if new_user is not None: - if not self._save_json(self.user_settings_path, new_user): - return False, "Failed to save imported user settings" - - if new_cal is not None: - if not self._save_json(self.calibration_data_path, new_cal): - # Roll back user_settings file if it was already written - if new_user is not None: - if not self._save_json(self.user_settings_path, orig_user): - logger.error("Failed to roll back user_settings after calibration save failure") - return False, "Failed to save imported calibration data" - - # Both saves succeeded, update in-memory state if new_user is not None: + self._settings.replace_all(_flatten(new_user)) self.user_settings = new_user if new_cal is not None: + self._calibration.replace_all(_flatten(new_cal)) self.calibration_data = new_cal self._rebuild_merged_config() diff --git a/Software/web-server/db/database.py b/Software/web-server/db/database.py index 5acbe389..f11dd5d0 100644 --- a/Software/web-server/db/database.py +++ b/Software/web-server/db/database.py @@ -1,7 +1,8 @@ import sqlite3 import threading +from contextlib import contextmanager from pathlib import Path -from typing import Any, List, Optional +from typing import Iterator, List, Optional MIGRATIONS_DIR = Path(__file__).parent / "migrations" @@ -45,5 +46,10 @@ def executemany(self, sql: str, rows: List[tuple]) -> None: self._conn.executemany(sql, rows) self._conn.commit() + @contextmanager + def transaction(self) -> Iterator[sqlite3.Connection]: + with self._lock, self._conn: + yield self._conn + def close(self) -> None: self._conn.close() diff --git a/Software/web-server/db/migrations/002_settings.sql b/Software/web-server/db/migrations/002_settings.sql new file mode 100644 index 00000000..6af2f871 --- /dev/null +++ b/Software/web-server/db/migrations/002_settings.sql @@ -0,0 +1,12 @@ +-- key is the dotted config key, value is JSON-encoded +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS calibration ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/Software/web-server/db/repositories.py b/Software/web-server/db/repositories.py index c1fca897..9998888d 100644 --- a/Software/web-server/db/repositories.py +++ b/Software/web-server/db/repositories.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple @@ -118,3 +119,24 @@ def shot_ids_with_images(self) -> set: def shot_ids_for_session(self, session_id: int) -> list: rows = self.db.query("SELECT id FROM shots WHERE session_id = ?", (session_id,)) return [r["id"] for r in rows] + + +class KeyValueRepository: + def __init__(self, db: Database, table: str): + if table not in ("settings", "calibration"): + raise ValueError(f"Unknown key-value table: {table}") + self.db = db + self.table = table + + def load(self) -> Dict[str, Any]: + rows = self.db.query(f"SELECT key, value FROM {self.table}") + return {r["key"]: json.loads(r["value"]) for r in rows} + + def replace_all(self, flat: Dict[str, Any]) -> None: + now = datetime.now().isoformat() + with self.db.transaction() as conn: + conn.execute(f"DELETE FROM {self.table}") + conn.executemany( + f"INSERT INTO {self.table} (key, value, updated_at) VALUES (?, ?, ?)", + [(key, json.dumps(value), now) for key, value in flat.items()], + ) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 4af73f42..e7caddea 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -193,14 +193,14 @@ def __init__(self): self.connection_manager = ConnectionManager() self.shot_store = ShotDataStore() self.parser = ShotDataParser() - self.config_manager = ConfigurationManager() + self.db = Database(DB_PATH) + self.config_manager = ConfigurationManager(self.db) self.pitrac_manager = PiTracProcessManager(self.config_manager) self.calibration_manager = CalibrationManager(self.config_manager) self.testing_manager = TestingToolsManager(self.config_manager) self.strobe_calibration_manager = StrobeCalibrationManager(self.config_manager) self.update_manager = UpdateManager() self.update_manager.set_broadcast_callback(self.connection_manager.broadcast) - self.db = Database(DB_PATH) self.session_repo = SessionRepository(self.db) self.shot_repo = ShotRepository(self.db) metadata = self.config_manager.load_configurations_metadata() diff --git a/Software/web-server/tests/conftest.py b/Software/web-server/tests/conftest.py index 5484f004..239f7b57 100644 --- a/Software/web-server/tests/conftest.py +++ b/Software/web-server/tests/conftest.py @@ -14,6 +14,11 @@ import tempfile os.environ.setdefault("PITRAC_DB_PATH", str(Path(tempfile.mkdtemp()) / "import-time.db")) +# server.py builds a server at import, which imports ~/.pitrac/config/*.json and renames them +os.environ["HOME"] = tempfile.mkdtemp() +# pitrac_manager tests patch Path.exists to True, so prune_run_logs stats the legacy log +(Path(os.environ["HOME"]) / ".pitrac" / "logs").mkdir(parents=True) +(Path(os.environ["HOME"]) / ".pitrac" / "logs" / "pitrac.log").touch() from models import ShotData from managers import ConnectionManager, ShotDataStore diff --git a/Software/web-server/tests/test_api_endpoints.py b/Software/web-server/tests/test_api_endpoints.py index 33551b17..e641401d 100644 --- a/Software/web-server/tests/test_api_endpoints.py +++ b/Software/web-server/tests/test_api_endpoints.py @@ -170,3 +170,11 @@ def test_internal_config_returns_merged_config(self, client, server_instance): body = resp.json() assert body == server_instance.config_manager.build_generated_config() assert "gs_config" in body + + def test_internal_config_reflects_calibration_put(self, client, server_instance): + resp = client.put("/api/config/gs_config.cameras.kCamera1FocalLength", json={"value": 6.25}) + assert resp.status_code == 200 + + body = client.get("/api/internal/config").json() + assert body["gs_config"]["cameras"]["kCamera1FocalLength"] == "6.25" + assert server_instance.config_manager._calibration.load() == {"gs_config.cameras.kCamera1FocalLength": 6.25} diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index 7b028bb7..ec983155 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -3,37 +3,35 @@ import json import os import pytest -import tempfile from pathlib import Path from unittest.mock import patch from config_manager import ConfigurationManager as ConfigManager +from db.database import Database +from db.repositories import KeyValueRepository -class TestConfigManager: - """Test the configuration manager functionality""" +@pytest.fixture +def db(tmp_path): + d = Database(tmp_path / "t.db") + yield d + d.close() - @pytest.fixture - def temp_config_dir(self): - """Create a temporary directory for config files""" - with tempfile.TemporaryDirectory() as tmpdir: - yield Path(tmpdir) - @pytest.fixture - def config_manager(self, temp_config_dir): - """Create a ConfigManager instance with temp paths""" - manager = ConfigManager() - manager.user_settings_path = temp_config_dir / "user_settings.json" - return manager +@pytest.fixture +def config_manager(db, tmp_path): + manager = ConfigManager(db) + manager.user_settings_path = tmp_path / "user_settings.json" + manager.calibration_data_path = tmp_path / "calibration_data.json" + return manager - @pytest.fixture - def setup_config_files(self, config_manager): - """Setup basic config files""" - user_settings = {"gs_config": {"cameras": {"kCamera1Gain": "2.0"}}} - with open(config_manager.user_settings_path, "w") as f: - json.dump(user_settings, f) +class TestConfigManager: + """Test the configuration manager functionality""" + @pytest.fixture + def setup_config_files(self, config_manager, db): + KeyValueRepository(db, "settings").replace_all({"gs_config.cameras.kCamera1Gain": "2.0"}) config_manager.reload() return config_manager @@ -131,13 +129,16 @@ def test_set_and_get_config(self, config_manager): gain_value = config_manager.get_config("gs_config.cameras.kCamera1Gain") assert gain_value is not None - def test_invalid_json_handling(self, config_manager): - """Test handling of invalid JSON files""" - with open(config_manager.user_settings_path, "w") as f: - f.write("{ invalid json }") + def test_invalid_json_file_is_left_alone(self, config_manager, db): + settings = KeyValueRepository(db, "settings") + settings.replace_all({"gs_config.cameras.kCamera1Gain": "3.0"}) + config_manager.user_settings_path.write_text("{ invalid json }") config_manager.reload() - assert config_manager.user_settings == {} + + assert config_manager.user_settings_path.exists() + assert settings.load() == {"gs_config.cameras.kCamera1Gain": "3.0"} + assert config_manager.user_settings == {"gs_config": {"cameras": {"kCamera1Gain": "3.0"}}} def test_missing_file_handling(self, config_manager): """Test handling of missing config files""" @@ -166,17 +167,76 @@ def test_nested_config_access(self, config_manager): address = config_manager.get_config("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress") assert address == "192.168.1.100" - def test_config_persistence(self, setup_config_files): - """Test that configuration methods work""" + def test_set_survives_reload(self, setup_config_files): config_manager = setup_config_files - result = config_manager.set_config("gs_config.cameras.kCamera1Gain", "3.5") - assert result is not None + config_manager.set_config("gs_config.cameras.kCamera1Gain", "3.5") + config_manager.reload() + + assert config_manager.get_config("gs_config.cameras.kCamera1Gain") == "3.5" + + def test_reset_all_clears_settings_and_keeps_calibration(self, config_manager, db): + config_manager.set_config("gs_config.cameras.kCamera1Gain", "3.5") + config_manager.set_config("gs_config.cameras.kCamera1FocalLength", 6.1) + + config_manager.reset_all() + + assert KeyValueRepository(db, "settings").load() == {} + assert KeyValueRepository(db, "calibration").load() == {"gs_config.cameras.kCamera1FocalLength": 6.1} + + def test_callback_fires_after_set_config(self, config_manager): + seen = [] + config_manager.register_callback("gs_config.cameras", lambda k, v: seen.append((k, v))) + + config_manager.set_config("gs_config.cameras.kCamera1Gain", "3.5") + + assert seen == [("gs_config.cameras.kCamera1Gain", "3.5")] + + def test_imports_json_files_once(self, config_manager, db, tmp_path): + config_manager.user_settings_path.write_text(json.dumps({ + "gs_config": {"golf_simulator_interfaces": {"GSPro": {"kGSProConnectAddress": "10.0.0.5"}}}, + "cameras.slot1.type": "4", + })) + config_manager.calibration_data_path.write_text(json.dumps({ + "gs_config": {"cameras": {"kCamera1FocalLength": 5.9, "kCamera1Angles": [2.14, -26.42]}}, + })) + + expected_settings = { + "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress": "10.0.0.5", + "cameras.slot1.type": "4", + } + expected_calibration = { + "gs_config.cameras.kCamera1FocalLength": 5.9, + "gs_config.cameras.kCamera1Angles": [2.14, -26.42], + } + settings = KeyValueRepository(db, "settings") + calibration = KeyValueRepository(db, "calibration") config_manager.reload() - gain_value = config_manager.get_config("gs_config.cameras.kCamera1Gain") - assert gain_value is not None + assert settings.load() == expected_settings + assert calibration.load() == expected_calibration + assert config_manager.get_config("cameras.slot1.type") == "4" + assert not config_manager.user_settings_path.exists() + assert not config_manager.calibration_data_path.exists() + assert (tmp_path / "user_settings.json.imported").exists() + assert (tmp_path / "calibration_data.json.imported").exists() + + config_manager.reload() + + assert settings.load() == expected_settings + assert calibration.load() == expected_calibration + + def test_later_user_settings_file_merges_over_rows(self, config_manager, db, tmp_path): + settings = KeyValueRepository(db, "settings") + settings.replace_all({"cameras.slot1.type": "4", "gs_config.cameras.kCamera1Gain": "3.0"}) + (tmp_path / "user_settings.json.imported").write_text("{}") + config_manager.user_settings_path.write_text(json.dumps({"cameras.slot1.type": "5"})) + + config_manager.reload() + + assert settings.load() == {"cameras.slot1.type": "5", "gs_config.cameras.kCamera1Gain": "3.0"} + assert not config_manager.user_settings_path.exists() @patch.dict(os.environ, {"HOME": "/test/home"}) def test_default_paths(self): @@ -214,20 +274,6 @@ def test_setting_validation_with_metadata(self, config_manager): class TestArrayHandling: """Test suite for array type configuration handling""" - @pytest.fixture - def temp_config_dir(self): - """Create a temporary directory for config files""" - with tempfile.TemporaryDirectory() as tmpdir: - yield Path(tmpdir) - - @pytest.fixture - def config_manager(self, temp_config_dir): - """Create a ConfigManager instance with temp paths""" - manager = ConfigManager() - manager.user_settings_path = temp_config_dir / "user_settings.json" - manager.calibration_data_path = temp_config_dir / "calibration_data.json" - return manager - def test_validate_array_with_list(self, config_manager): """Test validation of array type with a proper list""" # Mock metadata to include array type setting @@ -562,21 +608,6 @@ def mock_metadata(): class TestSetCalibrationBatch: """Atomic multi-key calibration writes used by distortion calibration save.""" - @pytest.fixture - def temp_config_dir(self): - with tempfile.TemporaryDirectory() as tmpdir: - yield Path(tmpdir) - - @pytest.fixture - def config_manager(self, temp_config_dir): - manager = ConfigManager() - manager.user_settings_path = temp_config_dir / "user_settings.json" - manager.calibration_data_path = temp_config_dir / "calibration_data.json" - manager.calibration_data = {} - manager.user_settings = {} - manager._rebuild_merged_config() - return manager - def test_empty_updates_returns_success(self, config_manager): ok, msg = config_manager.set_calibration_batch({}) assert ok @@ -594,13 +625,12 @@ def test_writes_both_keys_atomically(self, config_manager): assert cams["kCamera1CalibrationMatrix"] == matrix assert cams["kCamera1DistortionVector"] == dist - def test_persists_to_calibration_data_file(self, config_manager): + def test_persists_to_calibration_table(self, config_manager, db): config_manager.set_calibration_batch({ "gs_config.cameras.kCamera1CalibrationMatrix": [[1.0]], }) - with open(config_manager.calibration_data_path) as f: - saved = json.load(f) - assert saved["gs_config"]["cameras"]["kCamera1CalibrationMatrix"] == [[1.0]] + saved = KeyValueRepository(db, "calibration").load() + assert saved["gs_config.cameras.kCamera1CalibrationMatrix"] == [[1.0]] def test_rejects_non_calibration_key(self, config_manager): ok, msg = config_manager.set_calibration_batch({ @@ -634,18 +664,6 @@ def test_callbacks_fire_for_each_key(self, config_manager): class TestBuildGeneratedConfig: """Tests for the extracted config dict builder.""" - @pytest.fixture - def temp_config_dir(self): - with tempfile.TemporaryDirectory() as tmpdir: - yield Path(tmpdir) - - @pytest.fixture - def config_manager(self, temp_config_dir): - manager = ConfigManager() - manager.user_settings_path = temp_config_dir / "user_settings.json" - manager.calibration_data_path = temp_config_dir / "calibration_data.json" - return manager - def test_build_generated_config_returns_merged_dict(self, config_manager): cfg = config_manager.build_generated_config() assert isinstance(cfg, dict) diff --git a/Software/web-server/tests/test_database.py b/Software/web-server/tests/test_database.py index e951d04d..1431026a 100644 --- a/Software/web-server/tests/test_database.py +++ b/Software/web-server/tests/test_database.py @@ -22,16 +22,16 @@ def test_migrations_apply_on_fresh_db(self, db): row["name"] for row in db.query("SELECT name FROM sqlite_master WHERE type='table'") } - assert {"sessions", "shots", "shot_images"} <= tables + assert {"sessions", "shots", "shot_images", "settings", "calibration"} <= tables def test_user_version_matches_latest_migration(self, db): version = db.query("PRAGMA user_version")[0][0] - assert version == 1 + assert version == 2 def test_reopening_is_idempotent(self, tmp_path): Database(tmp_path / "test.db").close() again = Database(tmp_path / "test.db") - assert again.query("PRAGMA user_version")[0][0] == 1 + assert again.query("PRAGMA user_version")[0][0] == 2 again.close() def test_wal_mode_enabled(self, db): @@ -44,7 +44,7 @@ def test_execute_returns_lastrowid(self, db): assert rowid == 1 def test_crash_recovery(self, tmp_path): - # First open — runs migrations, sets user_version=1 + # First open runs migrations db1 = Database(tmp_path / "test.db") db1.close() @@ -59,7 +59,7 @@ def test_crash_recovery(self, tmp_path): version = db2.query("PRAGMA user_version")[0][0] db2.close() - assert version == 1 + assert version == 2 def test_fk_enforcement(self, db): # Inserting a shot with a session_id that doesn't exist must fail @@ -68,3 +68,10 @@ def test_fk_enforcement(self, db): "INSERT INTO shots (id, session_id, created_at, result_type) VALUES (?, ?, ?, ?)", (1699999999000, 999, "2026-06-01T10:00:00", "Normal"), ) + + def test_transaction_rolls_back_on_exception(self, db): + with pytest.raises(RuntimeError): + with db.transaction() as conn: + conn.execute("INSERT INTO sessions (started_at) VALUES (?)", ("2026-06-01T10:00:00",)) + raise RuntimeError("boom") + assert db.query("SELECT COUNT(*) FROM sessions")[0][0] == 0 diff --git a/Software/web-server/tests/test_repositories.py b/Software/web-server/tests/test_repositories.py index fdc36153..2d4d1167 100644 --- a/Software/web-server/tests/test_repositories.py +++ b/Software/web-server/tests/test_repositories.py @@ -6,7 +6,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from db.database import Database -from db.repositories import SessionRepository, ShotRepository +from db.repositories import KeyValueRepository, SessionRepository, ShotRepository from models import ShotData @@ -138,3 +138,17 @@ def test_delete_images_for_session_removes_only_that_session(self, sessions, sho remaining = db.query("SELECT file_path FROM shot_images") assert len(remaining) == 1 assert remaining[0]["file_path"] == "s2.png" + + +@pytest.mark.unit +class TestKeyValueRepository: + def test_replace_all_round_trips_values_and_drops_old_rows(self, db): + repo = KeyValueRepository(db, "calibration") + repo.replace_all({"stale.key": 1}) + flat = {"gs_config.cameras.kCamera1Angles": [2.14, -26.42], "gs_config.cameras.kCamera1FocalLength": 5.9} + repo.replace_all(flat) + assert repo.load() == flat + + def test_rejects_unknown_table(self, db): + with pytest.raises(ValueError): + KeyValueRepository(db, "shots") From 03daa25a137a98251746da225291e14b2459bdd7 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 8 Jul 2026 18:58:46 -0400 Subject: [PATCH 048/156] skip dotted keys on import, keep the first backup, import_config in one transaction --- Software/web-server/config_manager.py | 37 +++++++++++----- Software/web-server/db/repositories.py | 15 ++++--- .../web-server/tests/test_api_endpoints.py | 8 +++- .../web-server/tests/test_config_manager.py | 42 +++++++++++++++++-- 4 files changed, 82 insertions(+), 20 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index c426c66b..43957085 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -13,6 +13,7 @@ import json import logging import os +from datetime import datetime from pathlib import Path from threading import RLock from typing import Any, Callable, Dict, List, Optional, Tuple @@ -97,9 +98,9 @@ def expand_path(path_str: str) -> Path: self._config_callbacks: Dict[str, List[Callable[[str, Any], None]]] = {} - db = db or Database(DB_PATH) - self._settings = KeyValueRepository(db, "settings") - self._calibration = KeyValueRepository(db, "calibration") + self._db = db or Database(DB_PATH) + self._settings = KeyValueRepository(self._db, "settings") + self._calibration = KeyValueRepository(self._db, "calibration") self.reload() @@ -167,11 +168,23 @@ def _import_json(self, repo: KeyValueRepository, path: Path) -> None: logger.warning(f"Not importing {path}: it is not a readable JSON object, keeping the stored values") return - # configure-cameras.sh writes dotted keys at the top level, so normalize before merging - merged = _deep_merge(_unflatten(repo.load()), _unflatten(_flatten(data))) - repo.replace_all(_flatten(merged)) - path.rename(path.with_name(path.name + ".imported")) - logger.info(f"Imported {path} into the {repo.table} table") + # get_config never resolved dotted top-level keys, so importing them would change behavior + dotted = [key for key in data if "." in key] + if dotted: + logger.info(f"Dropping dotted top-level keys from {path}: {', '.join(dotted)}") + data = {key: value for key, value in data.items() if "." not in key} + + repo.replace_all(_flatten(_deep_merge(_unflatten(repo.load()), data))) + + target = path.with_name(path.name + ".imported") + if target.exists(): + target = path.with_name(f"{target.name}.{datetime.now():%Y%m%d-%H%M%S}") + try: + path.rename(target) + except OSError as e: + logger.error(f"Imported {path} but could not rename it to {target}: {e}") + raise + logger.info(f"Imported {path} into the {repo.table} table, renamed to {target.name}") def _build_config_from_metadata(self) -> Dict[str, Any]: """Build configuration from metadata defaults, calibration data, and user overrides""" @@ -819,11 +832,15 @@ def import_config(self, import_data: Dict[str, Any]) -> Tuple[bool, str]: if isinstance(val, dict): new_cal = copy.deepcopy(val) + with self._db.transaction() as conn: + if new_user is not None: + self._settings.replace_all_in(conn, _flatten(new_user)) + if new_cal is not None: + self._calibration.replace_all_in(conn, _flatten(new_cal)) + if new_user is not None: - self._settings.replace_all(_flatten(new_user)) self.user_settings = new_user if new_cal is not None: - self._calibration.replace_all(_flatten(new_cal)) self.calibration_data = new_cal self._rebuild_merged_config() diff --git a/Software/web-server/db/repositories.py b/Software/web-server/db/repositories.py index 9998888d..fa580f17 100644 --- a/Software/web-server/db/repositories.py +++ b/Software/web-server/db/repositories.py @@ -133,10 +133,13 @@ def load(self) -> Dict[str, Any]: return {r["key"]: json.loads(r["value"]) for r in rows} def replace_all(self, flat: Dict[str, Any]) -> None: - now = datetime.now().isoformat() with self.db.transaction() as conn: - conn.execute(f"DELETE FROM {self.table}") - conn.executemany( - f"INSERT INTO {self.table} (key, value, updated_at) VALUES (?, ?, ?)", - [(key, json.dumps(value), now) for key, value in flat.items()], - ) + self.replace_all_in(conn, flat) + + def replace_all_in(self, conn, flat: Dict[str, Any]) -> None: + now = datetime.now().isoformat() + conn.execute(f"DELETE FROM {self.table}") + conn.executemany( + f"INSERT INTO {self.table} (key, value, updated_at) VALUES (?, ?, ?)", + [(key, json.dumps(value), now) for key, value in flat.items()], + ) diff --git a/Software/web-server/tests/test_api_endpoints.py b/Software/web-server/tests/test_api_endpoints.py index e641401d..59cdf06f 100644 --- a/Software/web-server/tests/test_api_endpoints.py +++ b/Software/web-server/tests/test_api_endpoints.py @@ -174,7 +174,13 @@ def test_internal_config_returns_merged_config(self, client, server_instance): def test_internal_config_reflects_calibration_put(self, client, server_instance): resp = client.put("/api/config/gs_config.cameras.kCamera1FocalLength", json={"value": 6.25}) assert resp.status_code == 200 + resp = client.put("/api/config/gs_config.cameras.kCamera1Angles", json={"value": [2.1, -26.4]}) + assert resp.status_code == 200 body = client.get("/api/internal/config").json() assert body["gs_config"]["cameras"]["kCamera1FocalLength"] == "6.25" - assert server_instance.config_manager._calibration.load() == {"gs_config.cameras.kCamera1FocalLength": 6.25} + assert body["gs_config"]["cameras"]["kCamera1Angles"] == [2.1, -26.4] + assert server_instance.config_manager._calibration.load() == { + "gs_config.cameras.kCamera1FocalLength": 6.25, + "gs_config.cameras.kCamera1Angles": [2.1, -26.4], + } diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index ec983155..9ba261f1 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -195,7 +195,7 @@ def test_callback_fires_after_set_config(self, config_manager): def test_imports_json_files_once(self, config_manager, db, tmp_path): config_manager.user_settings_path.write_text(json.dumps({ "gs_config": {"golf_simulator_interfaces": {"GSPro": {"kGSProConnectAddress": "10.0.0.5"}}}, - "cameras.slot1.type": "4", + "cameras": {"slot1": {"type": "4"}}, })) config_manager.calibration_data_path.write_text(json.dumps({ "gs_config": {"cameras": {"kCamera1FocalLength": 5.9, "kCamera1Angles": [2.14, -26.42]}}, @@ -230,14 +230,50 @@ def test_imports_json_files_once(self, config_manager, db, tmp_path): def test_later_user_settings_file_merges_over_rows(self, config_manager, db, tmp_path): settings = KeyValueRepository(db, "settings") settings.replace_all({"cameras.slot1.type": "4", "gs_config.cameras.kCamera1Gain": "3.0"}) - (tmp_path / "user_settings.json.imported").write_text("{}") - config_manager.user_settings_path.write_text(json.dumps({"cameras.slot1.type": "5"})) + config_manager.user_settings_path.write_text(json.dumps({"cameras": {"slot1": {"type": "5"}}})) config_manager.reload() assert settings.load() == {"cameras.slot1.type": "5", "gs_config.cameras.kCamera1Gain": "3.0"} assert not config_manager.user_settings_path.exists() + def test_second_import_keeps_first_backup(self, config_manager, tmp_path): + first_backup = tmp_path / "user_settings.json.imported" + first_backup.write_text('{"original": true}') + config_manager.user_settings_path.write_text("{}") + + config_manager.reload() + + assert first_backup.read_text() == '{"original": true}' + assert len(list(tmp_path.glob("user_settings.json.imported.*"))) == 1 + + def test_import_drops_dotted_top_level_keys(self, config_manager, db): + config_manager.user_settings_path.write_text(json.dumps({ + "cameras": {"slot1": {"type": "5"}}, + "cameras.slot1.type": "4", + })) + + config_manager.reload() + + assert KeyValueRepository(db, "settings").load() == {"cameras.slot1.type": "5"} + assert config_manager.get_config("cameras.slot1.type") == "5" + + def test_import_config_failure_leaves_settings_untouched(self, config_manager, db): + config_manager.set_config("gs_config.cameras.kCamera1Gain", "3.5") + + def fail(conn, flat): + raise RuntimeError("disk full") + + config_manager._calibration.replace_all_in = fail + ok, _ = config_manager.import_config({ + "user_settings": {"gs_config": {"cameras": {"kCamera1Gain": "9.0"}}}, + "calibration_data": {"gs_config": {"cameras": {"kCamera1FocalLength": 6.1}}}, + }) + + assert not ok + assert KeyValueRepository(db, "settings").load() == {"gs_config.cameras.kCamera1Gain": "3.5"} + assert config_manager.get_config("gs_config.cameras.kCamera1Gain") == "3.5" + @patch.dict(os.environ, {"HOME": "/test/home"}) def test_default_paths(self): """Test default configuration file paths""" From 227675a89aaf8d6ae285e1b198f6f308a4a847ad Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 8 Jul 2026 19:19:17 -0400 Subject: [PATCH 049/156] prune the open session too, keeping the newest shot --- Software/web-server/db/repositories.py | 10 ++++++ Software/web-server/retention.py | 15 +++++++++ Software/web-server/tests/test_retention.py | 34 +++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/Software/web-server/db/repositories.py b/Software/web-server/db/repositories.py index fa580f17..dc7139d9 100644 --- a/Software/web-server/db/repositories.py +++ b/Software/web-server/db/repositories.py @@ -111,6 +111,16 @@ def delete_images_for_session(self, session_id: int) -> None: (session_id,), ) + def oldest_shot_with_images_excluding_latest(self) -> Optional[int]: + rows = self.db.query( + """SELECT MIN(shot_id) AS id FROM shot_images + WHERE shot_id != (SELECT MAX(id) FROM shots)""" + ) + return rows[0]["id"] + + def delete_images_for_shot(self, shot_id: int) -> None: + self.db.execute("DELETE FROM shot_images WHERE shot_id = ?", (shot_id,)) + def shot_ids_with_images(self) -> set: """Set of shot ids that have at least one shot_images row.""" rows = self.db.query("SELECT DISTINCT shot_id FROM shot_images") diff --git a/Software/web-server/retention.py b/Software/web-server/retention.py index 8b32c592..cb1c1e9e 100644 --- a/Software/web-server/retention.py +++ b/Software/web-server/retention.py @@ -45,6 +45,21 @@ def prune(self, now: datetime | None = None) -> None: total = _dir_bytes(shots_dir) logger.info(f"pruned session {sid} images; remaining {total // (1024*1024)}MB") + # Open session: oldest shots first, never the newest (the dashboard shows it). + while total > self._cap_bytes: + shot_id = self._shots.oldest_shot_with_images_excluding_latest() + if shot_id is None: + break + for img in self._shots.get(shot_id)["images"]: + (self._images_dir / img["file_path"]).unlink(missing_ok=True) + try: + (shots_dir / str(shot_id)).rmdir() + except OSError: + pass + self._shots.delete_images_for_shot(shot_id) + total = _dir_bytes(shots_dir) + logger.info(f"pruned shot {shot_id} images; remaining {total // (1024*1024)}MB") + # Step 3: orphan sweep — shots/ dirs with no shot_images rows. # C++ may write images for error-status shots that never get persisted. if total > self._cap_bytes and shots_dir.is_dir(): diff --git a/Software/web-server/tests/test_retention.py b/Software/web-server/tests/test_retention.py index ec4ef151..5956e154 100644 --- a/Software/web-server/tests/test_retention.py +++ b/Software/web-server/tests/test_retention.py @@ -133,6 +133,40 @@ def test_terminates_when_only_unprunable_remains(self, session_repo, shot_repo, # image still there assert (images_dir / "shots" / "20" / "spin1.png").exists() + def test_open_session_prunes_oldest_keeps_newest(self, session_repo, shot_repo, images_dir): + files = [] + for shot_id in (1, 2, 3): + _, f = _make_session(session_repo, shot_repo, images_dir, + shot_id=shot_id, size_bytes=300 * 1024) + files.append(f) + + r = ImageRetention(session_repo, shot_repo, images_dir, cap_mb=0) + r._cap_bytes = 400 * 1024 + r.prune() + + assert not files[0].exists() and not files[1].exists() + assert not files[0].parent.exists() + assert files[2].exists(), "newest shot images must survive" + assert len(shot_repo.get(3)["images"]) == 1 + assert shot_repo.get(1)["images"] == [] + assert shot_repo.get(2)["images"] == [] + + def test_ended_sessions_pruned_before_open_session(self, session_repo, shot_repo, images_dir): + _, ended = _make_session(session_repo, shot_repo, images_dir, shot_id=1, + ts="2026-06-01T09:00:00", size_bytes=300 * 1024) + session_repo.close_open("2026-06-01T09:30:00") + _, old_open = _make_session(session_repo, shot_repo, images_dir, shot_id=2, + ts="2026-06-01T10:00:00", size_bytes=300 * 1024) + _, newest = _make_session(session_repo, shot_repo, images_dir, shot_id=3, + ts="2026-06-01T10:01:00", size_bytes=300 * 1024) + + r = ImageRetention(session_repo, shot_repo, images_dir, cap_mb=0) + r._cap_bytes = 700 * 1024 + r.prune() + + assert not ended.exists() + assert old_open.exists() and newest.exists() + def test_orphan_dir_pruned_when_old_and_over_cap(self, session_repo, shot_repo, images_dir): # create an orphan dir — no shot_images row orphan_dir = images_dir / "shots" / "999" From e13290ea3fba2c9a2cc5014e13d8d1c1cd0a9ed9 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 8 Jul 2026 20:55:38 -0400 Subject: [PATCH 050/156] rebuild sims when their settings change instead of needing a restart --- Software/web-server/server.py | 1 + Software/web-server/sim_manager.py | 27 ++++++++++ Software/web-server/tests/test_sim_manager.py | 54 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index e7caddea..2a96b3ad 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1505,6 +1505,7 @@ async def startup_event(self) -> None: logger.info("Starting PiTrac Web Server...") loop = asyncio.get_event_loop() self.calibration_manager.loop = loop + self.sim_manager.loop = loop await self.calibration_manager._replay_pending_updates() # V3 boards: write calibrated DAC value to hardware before any strobe fires. diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index f0310715..134c178d 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from typing import Awaitable, Callable, Dict, List, Optional @@ -13,10 +14,15 @@ class SimManager: + RELOAD_DEBOUNCE_SEC = 0.5 + def __init__(self, config_manager, broadcast: Optional[BroadcastFn] = None) -> None: self.config_manager = config_manager self._broadcast = broadcast self._sims: Dict[str, SimInterface] = {} + self.loop: Optional[asyncio.AbstractEventLoop] = None + self._reload_task: Optional[asyncio.Task] = None + config_manager.register_callback("simulators.", self._on_config_change) def build_sims(self) -> None: self._sims = {} @@ -37,6 +43,27 @@ async def start(self) -> None: await self._broadcast_status() async def stop(self) -> None: + if self._reload_task is not None: + self._reload_task.cancel() + self._reload_task = None + await self._disconnect_all() + + def _on_config_change(self, key: str, value: object) -> None: + # Runs on the config writer's thread; startup builds the sims, so ignore until the loop is set. + if self.loop is not None: + self.loop.call_soon_threadsafe(self._schedule_reload) + + def _schedule_reload(self) -> None: + if self._reload_task is not None: + self._reload_task.cancel() + self._reload_task = asyncio.create_task(self._reload_after_debounce()) + + async def _reload_after_debounce(self) -> None: + await asyncio.sleep(self.RELOAD_DEBOUNCE_SEC) + await self._disconnect_all() + await self.start() + + async def _disconnect_all(self) -> None: for sim in self._sims.values(): try: await sim.disconnect() diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index e3a27185..d098242d 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -13,6 +13,9 @@ def __init__(self, values): def get_config(self, key): return self._v.get(key) + def register_callback(self, pattern, callback): + self.callbacks = getattr(self, "callbacks", []) + [(pattern, callback)] + class _StubSim: def __init__(self): @@ -94,3 +97,54 @@ async def test_connect_disconnect_by_name(): assert sim.connected is True await mgr.disconnect("stub") assert sim.connected is False + + +def _ogs_config(enabled): + return _StubConfig({ + "simulators.ogs.enabled": enabled, "simulators.ogs.auto_connect": False, + "simulators.ogs.host": "1.2.3.4", "simulators.ogs.port": 3111, + "simulators.ogs.keepalive_sec": 5, + }) + + +@pytest.mark.asyncio +async def test_config_change_rebuilds_sims(monkeypatch): + monkeypatch.setattr(SimManager, "RELOAD_DEBOUNCE_SEC", 0.01) + cfg = _ogs_config(False) + mgr = SimManager(cfg, broadcast=None) + mgr.loop = asyncio.get_running_loop() + mgr.build_sims() + assert mgr.status() == [] + assert cfg.callbacks[0][0] == "simulators." + cfg._v["simulators.ogs.enabled"] = True + mgr._on_config_change("simulators.ogs.enabled", True) + await asyncio.sleep(0.1) + assert [s["name"] for s in mgr.status()] == ["ogs"] + + +@pytest.mark.asyncio +async def test_burst_of_changes_reloads_once(monkeypatch): + monkeypatch.setattr(SimManager, "RELOAD_DEBOUNCE_SEC", 0.05) + mgr = SimManager(_ogs_config(True), broadcast=None) + mgr.loop = asyncio.get_running_loop() + builds = [] + mgr.build_sims = lambda: builds.append(1) + for key in ("host", "port", "keepalive_sec"): + mgr._on_config_change(f"simulators.ogs.{key}", 1) + await asyncio.sleep(0.01) + await asyncio.sleep(0.2) + assert len(builds) == 1 + + +@pytest.mark.asyncio +async def test_stop_cancels_pending_reload(monkeypatch): + monkeypatch.setattr(SimManager, "RELOAD_DEBOUNCE_SEC", 0.05) + mgr = SimManager(_ogs_config(True), broadcast=None) + mgr.loop = asyncio.get_running_loop() + builds = [] + mgr.build_sims = lambda: builds.append(1) + mgr._on_config_change("simulators.ogs.host", "x") + await asyncio.sleep(0) + await mgr.stop() + await asyncio.sleep(0.15) + assert builds == [] From 99b9b683c74729b5df5cb8dfc817e4c222101dc3 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 9 Jul 2026 20:49:33 -0400 Subject: [PATCH 051/156] detect cameras from the web server on first start instead of the install script --- Software/web-server/server.py | 22 ++++++ .../tests/test_camera_autodetect.py | 39 ++++++++++ packaging/scripts/configure-cameras.sh | 73 ------------------- 3 files changed, 61 insertions(+), 73 deletions(-) create mode 100644 Software/web-server/tests/test_camera_autodetect.py diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 2a96b3ad..894e4bdc 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1519,10 +1519,32 @@ async def startup_event(self) -> None: self.background_tasks.add(task) task.add_done_callback(self.background_tasks.discard) + task = asyncio.create_task(self._detect_cameras_if_unset()) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + await self.sim_manager.start() logger.info("PiTrac Web Server ready — receiving results via HTTP POST") + async def _detect_cameras_if_unset(self) -> None: + if "cameras.slot1.type" in self.config_manager._settings.load(): + return + try: + result = await asyncio.to_thread(lambda: CameraDetector().detect()) + except Exception: + logger.exception("Camera detection failed; using default camera settings") + return + cameras = result.get("cameras", []) + if not cameras: + logger.info("No cameras detected; using default camera settings") + return + for slot in range(1, min(len(cameras), 2) + 1): + detected = result["configuration"][f"slot{slot}"] + self.config_manager.set_config(f"cameras.slot{slot}.type", str(detected["type"])) + self.config_manager.set_config(f"cameras.slot{slot}.lens", str(detected["lens"])) + logger.info(f"Saved detected settings for {len(cameras)} camera(s)") + async def _run_tool_async(self, tool_id: str) -> None: """Helper method to run a testing tool asynchronously""" try: diff --git a/Software/web-server/tests/test_camera_autodetect.py b/Software/web-server/tests/test_camera_autodetect.py new file mode 100644 index 00000000..9fbe3250 --- /dev/null +++ b/Software/web-server/tests/test_camera_autodetect.py @@ -0,0 +1,39 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +def detection(count): + return { + "success": True, + "cameras": [{"index": i} for i in range(count)], + "configuration": {"slot1": {"type": 4, "lens": 1}, "slot2": {"type": 4, "lens": 1}}, + } + + +@pytest.mark.unit +class TestCameraAutodetect: + async def test_saves_detected_slot_on_empty_settings(self, server_instance): + with patch("server.CameraDetector") as detector: + detector.return_value.detect.return_value = detection(1) + detector.return_value.detect.return_value["configuration"]["slot1"]["lens"] = 2 + await server_instance._detect_cameras_if_unset() + + saved = server_instance.config_manager._settings.load() + assert saved["cameras.slot1.type"] == "4" + assert saved["cameras.slot1.lens"] == "2" + assert not any(key.startswith("cameras.slot2") for key in saved) + + async def test_existing_slot_type_is_left_alone(self, server_instance): + server_instance.config_manager.set_config("cameras.slot1.type", "4") + with patch("server.CameraDetector") as detector: + await server_instance._detect_cameras_if_unset() + + detector.assert_not_called() + assert server_instance.config_manager._settings.load()["cameras.slot1.type"] == "4" + + async def test_detection_failure_leaves_settings_empty(self, server_instance): + with patch("server.CameraDetector", MagicMock(side_effect=RuntimeError("no libcamera"))): + await server_instance._detect_cameras_if_unset() + + assert "cameras.slot1.type" not in server_instance.config_manager._settings.load() diff --git a/packaging/scripts/configure-cameras.sh b/packaging/scripts/configure-cameras.sh index 9b9abf96..8476d6b7 100755 --- a/packaging/scripts/configure-cameras.sh +++ b/packaging/scripts/configure-cameras.sh @@ -291,70 +291,6 @@ dtoverlay=vc_mipi_imx296" log_warn "IMPORTANT: System must be rebooted for camera configuration changes to take effect" } -# Configure user_settings.json based on detected cameras -configure_user_settings() { - local camera_json="$1" - local user_settings_path="${2:-${HOME}/.pitrac/config/user_settings.json}" - - mkdir -p "$(dirname "$user_settings_path")" - - # Parse camera configuration - local slot1_type=$(echo "$camera_json" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('configuration', {}).get('slot1', {}).get('type', ''))" 2>/dev/null || echo "") - local slot1_lens=$(echo "$camera_json" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('configuration', {}).get('slot1', {}).get('lens', '1'))" 2>/dev/null || echo "1") - local slot2_type=$(echo "$camera_json" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('configuration', {}).get('slot2', {}).get('type', ''))" 2>/dev/null || echo "") - local slot2_lens=$(echo "$camera_json" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('configuration', {}).get('slot2', {}).get('lens', '1'))" 2>/dev/null || echo "1") - - log_info "Configuring user settings at ${user_settings_path}..." - - # Create or update user_settings.json using Python for proper JSON handling - python3 < Date: Sat, 11 Jul 2026 21:21:10 -0400 Subject: [PATCH 052/156] only detect cameras when the database was just created --- Software/web-server/db/database.py | 1 + Software/web-server/server.py | 2 +- Software/web-server/tests/test_camera_autodetect.py | 9 ++++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Software/web-server/db/database.py b/Software/web-server/db/database.py index f11dd5d0..30efe5bb 100644 --- a/Software/web-server/db/database.py +++ b/Software/web-server/db/database.py @@ -21,6 +21,7 @@ def __init__(self, db_path: Path): def _migrate(self) -> None: current = self._conn.execute("PRAGMA user_version").fetchone()[0] + self.created = current == 0 for script in sorted(MIGRATIONS_DIR.glob("*.sql"), key=lambda p: int(p.name.split("_")[0])): version = int(script.name.split("_")[0]) if version <= current: diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 894e4bdc..9f03e6e3 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1528,7 +1528,7 @@ async def startup_event(self) -> None: logger.info("PiTrac Web Server ready — receiving results via HTTP POST") async def _detect_cameras_if_unset(self) -> None: - if "cameras.slot1.type" in self.config_manager._settings.load(): + if not self.db.created or "cameras.slot1.type" in self.config_manager._settings.load(): return try: result = await asyncio.to_thread(lambda: CameraDetector().detect()) diff --git a/Software/web-server/tests/test_camera_autodetect.py b/Software/web-server/tests/test_camera_autodetect.py index 9fbe3250..8dcdc649 100644 --- a/Software/web-server/tests/test_camera_autodetect.py +++ b/Software/web-server/tests/test_camera_autodetect.py @@ -24,13 +24,20 @@ async def test_saves_detected_slot_on_empty_settings(self, server_instance): assert saved["cameras.slot1.lens"] == "2" assert not any(key.startswith("cameras.slot2") for key in saved) + async def test_existing_db_never_runs_detection(self, server_instance): + server_instance.db.created = False + with patch("server.CameraDetector") as detector: + await server_instance._detect_cameras_if_unset() + + detector.assert_not_called() + assert "cameras.slot1.type" not in server_instance.config_manager._settings.load() + async def test_existing_slot_type_is_left_alone(self, server_instance): server_instance.config_manager.set_config("cameras.slot1.type", "4") with patch("server.CameraDetector") as detector: await server_instance._detect_cameras_if_unset() detector.assert_not_called() - assert server_instance.config_manager._settings.load()["cameras.slot1.type"] == "4" async def test_detection_failure_leaves_settings_empty(self, server_instance): with patch("server.CameraDetector", MagicMock(side_effect=RuntimeError("no libcamera"))): From 77ddfb66a938f478bf013adae529a82a6fa9e2ff Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 11 Jul 2026 23:45:00 -0400 Subject: [PATCH 053/156] skip camera detection when the first-start import already brought settings --- Software/web-server/server.py | 2 +- Software/web-server/tests/test_camera_autodetect.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 9f03e6e3..2cff8587 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1528,7 +1528,7 @@ async def startup_event(self) -> None: logger.info("PiTrac Web Server ready — receiving results via HTTP POST") async def _detect_cameras_if_unset(self) -> None: - if not self.db.created or "cameras.slot1.type" in self.config_manager._settings.load(): + if not self.db.created or self.config_manager.get_user_settings(): return try: result = await asyncio.to_thread(lambda: CameraDetector().detect()) diff --git a/Software/web-server/tests/test_camera_autodetect.py b/Software/web-server/tests/test_camera_autodetect.py index 8dcdc649..391e433c 100644 --- a/Software/web-server/tests/test_camera_autodetect.py +++ b/Software/web-server/tests/test_camera_autodetect.py @@ -30,10 +30,12 @@ async def test_existing_db_never_runs_detection(self, server_instance): await server_instance._detect_cameras_if_unset() detector.assert_not_called() - assert "cameras.slot1.type" not in server_instance.config_manager._settings.load() - async def test_existing_slot_type_is_left_alone(self, server_instance): - server_instance.config_manager.set_config("cameras.slot1.type", "4") + async def test_imported_settings_without_cameras_skip_detection(self, server_instance): + manager = server_instance.config_manager + manager.user_settings_path.parent.mkdir(parents=True, exist_ok=True) + manager.user_settings_path.write_text('{"gs_config": {"logging": {"kLogLevel": "debug"}}}') + manager.reload() with patch("server.CameraDetector") as detector: await server_instance._detect_cameras_if_unset() From 5a6c83112d1f9ada978386ece7b4fe8100da6463 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 13 Jul 2026 18:19:04 -0400 Subject: [PATCH 054/156] skip camera detection when an old settings json was found --- Software/web-server/config_manager.py | 2 ++ Software/web-server/server.py | 2 +- Software/web-server/tests/test_camera_autodetect.py | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index 43957085..e91d2f21 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -102,6 +102,7 @@ def expand_path(path_str: str) -> Path: self._settings = KeyValueRepository(self._db, "settings") self._calibration = KeyValueRepository(self._db, "calibration") + self.found_legacy_json = False self.reload() def _load_raw_metadata(self) -> Dict[str, Any]: @@ -131,6 +132,7 @@ def reload(self) -> None: """Reload configuration from metadata, calibration data, and user settings""" with self._lock: self._metadata_cache = None + self.found_legacy_json |= self.user_settings_path.exists() or self.calibration_data_path.exists() self._import_json(self._settings, self.user_settings_path) self._import_json(self._calibration, self.calibration_data_path) self.user_settings = _unflatten(self._settings.load()) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 2cff8587..be24422b 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1528,7 +1528,7 @@ async def startup_event(self) -> None: logger.info("PiTrac Web Server ready — receiving results via HTTP POST") async def _detect_cameras_if_unset(self) -> None: - if not self.db.created or self.config_manager.get_user_settings(): + if not self.db.created or self.config_manager.found_legacy_json: return try: result = await asyncio.to_thread(lambda: CameraDetector().detect()) diff --git a/Software/web-server/tests/test_camera_autodetect.py b/Software/web-server/tests/test_camera_autodetect.py index 391e433c..2d76631f 100644 --- a/Software/web-server/tests/test_camera_autodetect.py +++ b/Software/web-server/tests/test_camera_autodetect.py @@ -31,10 +31,10 @@ async def test_existing_db_never_runs_detection(self, server_instance): detector.assert_not_called() - async def test_imported_settings_without_cameras_skip_detection(self, server_instance): + async def test_legacy_settings_file_skips_detection(self, server_instance): manager = server_instance.config_manager manager.user_settings_path.parent.mkdir(parents=True, exist_ok=True) - manager.user_settings_path.write_text('{"gs_config": {"logging": {"kLogLevel": "debug"}}}') + manager.user_settings_path.write_text('{"cameras.slot1.type": "5"}') manager.reload() with patch("server.CameraDetector") as detector: await server_instance._detect_cameras_if_unset() From 47a55a48cc6684cc7d03023573911360c492e94d Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 13 Jul 2026 20:39:53 -0400 Subject: [PATCH 055/156] skip detected camera settings that aren't valid options --- Software/web-server/server.py | 9 +++++++-- Software/web-server/tests/test_camera_autodetect.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index be24422b..136c7fe3 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1541,8 +1541,13 @@ async def _detect_cameras_if_unset(self) -> None: return for slot in range(1, min(len(cameras), 2) + 1): detected = result["configuration"][f"slot{slot}"] - self.config_manager.set_config(f"cameras.slot{slot}.type", str(detected["type"])) - self.config_manager.set_config(f"cameras.slot{slot}.lens", str(detected["lens"])) + for field in ("type", "lens"): + key, value = f"cameras.slot{slot}.{field}", str(detected[field]) + valid, _ = self.config_manager.validate_config(key, value) + if valid: + self.config_manager.set_config(key, value) + else: + logger.warning(f"Detected {key} = {value} is not a supported option; keeping the default") logger.info(f"Saved detected settings for {len(cameras)} camera(s)") async def _run_tool_async(self, tool_id: str) -> None: diff --git a/Software/web-server/tests/test_camera_autodetect.py b/Software/web-server/tests/test_camera_autodetect.py index 2d76631f..c284845e 100644 --- a/Software/web-server/tests/test_camera_autodetect.py +++ b/Software/web-server/tests/test_camera_autodetect.py @@ -46,3 +46,13 @@ async def test_detection_failure_leaves_settings_empty(self, server_instance): await server_instance._detect_cameras_if_unset() assert "cameras.slot1.type" not in server_instance.config_manager._settings.load() + + async def test_unsupported_detected_type_is_not_saved(self, server_instance): + with patch("server.CameraDetector") as detector: + detector.return_value.detect.return_value = detection(1) + detector.return_value.detect.return_value["configuration"]["slot1"].update(type=0, lens=2) + await server_instance._detect_cameras_if_unset() + + saved = server_instance.config_manager._settings.load() + assert "cameras.slot1.type" not in saved + assert saved["cameras.slot1.lens"] == "2" From 01819bdd37436455d95101ec1d6165167f31b02b Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 16 Jul 2026 21:30:44 -0400 Subject: [PATCH 056/156] log rejected calibration puts, fix share dir key, name camera settings in errors --- .../LMSourceCode/ImageProcessing/camera_hardware.cpp | 6 +++--- Software/LMSourceCode/ImageProcessing/gs_config.cpp | 2 +- .../LMSourceCode/ImageProcessing/gs_http_client.cpp | 10 +++++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp index dbf930ca..59a11290 100644 --- a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp +++ b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp @@ -91,7 +91,7 @@ namespace golf_sim { { "100", CameraModel::kCameraUnknown }, }; if (camera_table.count(model_enum_value_string) == 0) - throw std::runtime_error("Invalid camera model_enum_value_string: " + model_enum_value_string + ". Expected an integer. Check environment variables."); + throw std::runtime_error("Invalid camera model_enum_value_string: " + model_enum_value_string + ". Expected an integer. Check the cameras.slotN.type setting."); CameraHardware::CameraModel camera_model = (CameraHardware::CameraModel)camera_table[model_enum_value_string]; @@ -107,7 +107,7 @@ namespace golf_sim { { "100", LensType::kLensUnknown }, }; if (lens_table.count(lens_enum_value_string) == 0) - throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check environment variables."); + throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check the cameras.slotN.lens setting."); CameraHardware::LensType lens_type = (CameraHardware::LensType)lens_table[lens_enum_value_string]; @@ -123,7 +123,7 @@ namespace golf_sim { { "100", CameraOrientation::kCameraOrientationUnknown }, }; if (orientation_table.count(lens_enum_value_string) == 0) - throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check environment variables."); + throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check the cameras.slotN.lens setting."); CameraHardware::CameraOrientation orientation_type = (CameraHardware::CameraOrientation)orientation_table[lens_enum_value_string]; diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index aac7dfbf..5056d0c3 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -261,7 +261,7 @@ bool GolfSimConfiguration::ReadValues() { } else { // Attempt to get the image logging directory from the .json config file - SetConstant("gs_config.user_interface.kWebServerShareDirectory", GsUISystem::kWebServerShareDirectory); + SetConstant("gs_config.ipc_interface.kWebServerShareDirectory", GsUISystem::kWebServerShareDirectory); } // If the configuration file forgot to add a "/" at the end of the logging directory, we should add it here ourselves diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp index dd849e1c..76d35d9a 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp @@ -84,7 +84,15 @@ bool GsHttpClient::PutJson(const std::string& path, const std::string& json_body cli.set_read_timeout(5); auto res = cli.Put(path, json_body, "application/json"); - return res && res->status == 200; + if (!res) { + GS_LOG_MSG(error, "HTTP PUT " + path + " failed (no response)"); + return false; + } + if (res->status != 200) { + GS_LOG_MSG(error, "HTTP PUT " + path + " returned " + std::to_string(res->status) + ": " + res->body); + return false; + } + return true; } catch (const std::exception& e) { GS_LOG_MSG(warning, "HTTP PUT exception: " + std::string(e.what())); return false; From 40d3bb04a0069671a184a1f0db5c526c79662c26 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 17 Jul 2026 16:30:41 -0400 Subject: [PATCH 057/156] name the orientation setting in the orientation error --- Software/LMSourceCode/ImageProcessing/camera_hardware.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp index 59a11290..ca77d54b 100644 --- a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp +++ b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp @@ -123,7 +123,7 @@ namespace golf_sim { { "100", CameraOrientation::kCameraOrientationUnknown }, }; if (orientation_table.count(lens_enum_value_string) == 0) - throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check the cameras.slotN.lens setting."); + throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check the cameras.slotN.orientation setting."); CameraHardware::CameraOrientation orientation_type = (CameraHardware::CameraOrientation)orientation_table[lens_enum_value_string]; From d545912b6e5cd7daf6f608b06abb15aa40fe487e Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 17 Jul 2026 22:17:40 -0400 Subject: [PATCH 058/156] use a lifespan handler and clean up the test warnings --- Software/web-server/pytest.ini | 1 + Software/web-server/server.py | 15 ++++++++------- .../web-server/tests/test_calibration_manager.py | 6 +++--- .../tests/test_testing_tools_manager.py | 8 ++++---- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Software/web-server/pytest.ini b/Software/web-server/pytest.ini index 264b64fa..465287f3 100644 --- a/Software/web-server/pytest.ini +++ b/Software/web-server/pytest.ini @@ -4,6 +4,7 @@ python_files = test_*.py python_classes = Test* python_functions = test_* asyncio_mode = auto +asyncio_default_fixture_loop_scope = function addopts = -v --tb=short diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 136c7fe3..03d4bedd 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -4,6 +4,7 @@ import os import shutil import subprocess +from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Union @@ -188,7 +189,7 @@ class PiTracServer: _BASE_DIR = Path(__file__).resolve().parent def __init__(self): - self.app = FastAPI(title="PiTrac Dashboard") + self.app = FastAPI(title="PiTrac Dashboard", lifespan=self._lifespan) self.templates = Jinja2Templates(directory=str(self._BASE_DIR / "templates")) self.connection_manager = ConnectionManager() self.shot_store = ShotDataStore() @@ -222,12 +223,12 @@ def __init__(self): self._setup_routes() - @self.app.on_event("startup") - async def _startup(): - await self.startup_event() - - @self.app.on_event("shutdown") - async def _shutdown(): + @asynccontextmanager + async def _lifespan(self, app: FastAPI): + await self.startup_event() + try: + yield + finally: await self.shutdown_event() def _setup_routes(self) -> None: diff --git a/Software/web-server/tests/test_calibration_manager.py b/Software/web-server/tests/test_calibration_manager.py index ac4ac7db..0a2c8712 100644 --- a/Software/web-server/tests/test_calibration_manager.py +++ b/Software/web-server/tests/test_calibration_manager.py @@ -481,7 +481,7 @@ async def test_check_ball_location_timeout(self): with patch("calibration_manager.asyncio.create_subprocess_exec") as mock_subprocess: mock_process = AsyncMock() mock_process.communicate.side_effect = asyncio.TimeoutError() - mock_process.terminate = AsyncMock() + mock_process.terminate = Mock() mock_process.wait = AsyncMock() mock_subprocess.return_value = mock_process @@ -564,7 +564,7 @@ async def test_stop_calibration_with_process(self): manager = CalibrationManager(mock_config_manager) mock_process = AsyncMock() - mock_process.terminate = AsyncMock() + mock_process.terminate = Mock() mock_process.wait = AsyncMock() mock_process.returncode = None manager.current_processes["camera1"] = mock_process @@ -585,7 +585,7 @@ async def test_stop_calibration_terminate_fails(self): manager = CalibrationManager(mock_config_manager) mock_process = AsyncMock() - mock_process.terminate.side_effect = Exception("Permission denied") + mock_process.terminate = Mock(side_effect=Exception("Permission denied")) mock_process.wait.side_effect = Exception("Wait failed") mock_process.returncode = None manager.current_processes["camera1"] = mock_process diff --git a/Software/web-server/tests/test_testing_tools_manager.py b/Software/web-server/tests/test_testing_tools_manager.py index dc13b653..5a03483c 100644 --- a/Software/web-server/tests/test_testing_tools_manager.py +++ b/Software/web-server/tests/test_testing_tools_manager.py @@ -5,7 +5,7 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, Mock, patch from constants import SERVER_PORT -from testing_tools_manager import TestingToolsManager +from testing_tools_manager import TestingToolsManager as ToolsManager @pytest.fixture @@ -25,7 +25,7 @@ def mock_config_manager(): def testing_manager(mock_config_manager, tmp_path): """Create TestingToolsManager instance for testing""" with patch("testing_tools_manager.Path.home", return_value=tmp_path): - manager = TestingToolsManager(mock_config_manager) + manager = ToolsManager(mock_config_manager) return manager @@ -146,7 +146,7 @@ async def test_run_tool_timeout(self, testing_manager, mock_config_manager): """Test tool timeout handling""" mock_process = AsyncMock() mock_process.communicate.side_effect = asyncio.TimeoutError() - mock_process.terminate = AsyncMock() + mock_process.terminate = Mock() mock_process.wait = AsyncMock() with patch("asyncio.create_subprocess_exec", return_value=mock_process): @@ -161,7 +161,7 @@ async def test_run_continuous_test_timeout(self, testing_manager, mock_config_ma """Test continuous test timeout behavior""" mock_process = AsyncMock() mock_process.communicate.side_effect = asyncio.TimeoutError() - mock_process.terminate = AsyncMock() + mock_process.terminate = Mock() mock_process.wait = AsyncMock() with patch("asyncio.create_subprocess_exec", return_value=mock_process): From ca557e233af4914b53fd670481452565843455b2 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 20 Jul 2026 18:35:40 -0400 Subject: [PATCH 059/156] drop the misspelled and duplicate warning flags from meson --- Software/LMSourceCode/ImageProcessing/meson.build | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index 81e2ef42..f8684d3e 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -31,7 +31,7 @@ add_global_arguments('-DHAS_NCNN', language : 'cpp') fs = import('fs') # TBD FIX - The following may be redundant of the above options -cpp_arguments = ['-pedantic', '-Wno-unused-parameter', '-faligned-new', '-DBOOST_LOG_DYN_LINK', '-Wno-error', '-Wno-deprecated-enum-enum-conversion', '-Wno-decprecated-declarations'] +cpp_arguments = ['-pedantic', '-Wno-unused-parameter', '-faligned-new', '-DBOOST_LOG_DYN_LINK', '-Wno-error'] # Needed for file sizes > 32-bits. cpp_arguments += '-D_FILE_OFFSET_BITS=64' From fcf07786c2a25bcbeea783da72427a286104a723 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 20 Jul 2026 18:43:22 -0400 Subject: [PATCH 060/156] point camera detect hints at the cameras.slotN.type settings --- Software/web-server/camera_detector.py | 6 +++--- detect_pi_cameras.sh | 11 +++-------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Software/web-server/camera_detector.py b/Software/web-server/camera_detector.py index e93a85e1..f2bf6e8a 100644 --- a/Software/web-server/camera_detector.py +++ b/Software/web-server/camera_detector.py @@ -662,9 +662,9 @@ def main(): print(f" Color Mode: {cam['cfa']}") print() - print("Recommended Configuration:") - print(f" export PITRAC_SLOT1_CAMERA_TYPE={result['configuration']['slot1']['type']}") - print(f" export PITRAC_SLOT2_CAMERA_TYPE={result['configuration']['slot2']['type']}") + print("Recommended Configuration (web UI Config page, Cameras section, or its Detect button):") + print(f" cameras.slot1.type = {result['configuration']['slot1']['type']}") + print(f" cameras.slot2.type = {result['configuration']['slot2']['type']}") else: print("\nNo cameras detected!") print("\nTroubleshooting:") diff --git a/detect_pi_cameras.sh b/detect_pi_cameras.sh index 6966a23b..dd7764e1 100755 --- a/detect_pi_cameras.sh +++ b/detect_pi_cameras.sh @@ -577,14 +577,9 @@ print_configuration() { echo -e "${BOLD}PiTrac Configuration:${NC}" echo - echo "YAML Configuration (/etc/pitrac/pitrac.yaml):" - echo " cameras:" - echo " slot1_type: $slot1_type" - echo " slot2_type: $slot2_type" - echo - echo "Environment Variables:" - echo " export PITRAC_SLOT1_CAMERA_TYPE=$slot1_type" - echo " export PITRAC_SLOT2_CAMERA_TYPE=$slot2_type" + echo "Web UI settings (Config page, Cameras section, or its Detect button):" + echo " cameras.slot1.type = $slot1_type" + echo " cameras.slot2.type = $slot2_type" echo # Recommendations From a3e491e3c8ac8e68b0292caee0ea7d94e5554916 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 20 Jul 2026 21:41:35 -0400 Subject: [PATCH 061/156] run one testing tool at a time and block pitrac start while one runs --- Software/web-server/server.py | 10 ++++++++++ Software/web-server/testing_tools_manager.py | 6 ++++-- .../web-server/tests/test_api_endpoints_pitrac.py | 12 ++++++++++++ .../web-server/tests/test_testing_tools_manager.py | 12 ++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 03d4bedd..1585e709 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -561,6 +561,8 @@ async def start_pitrac() -> Dict[str, Any]: safety = self.strobe_calibration_manager.is_strobe_safe() if not safety["safe"]: return {"status": "error", "message": safety["reason"]} + if refusal := self._testing_tool_refusal(): + return refusal result = await self.pitrac_manager.start() logger.info(f"PiTrac start request: {result}") return result @@ -580,6 +582,8 @@ async def restart_pitrac() -> Dict[str, Any]: safety = self.strobe_calibration_manager.is_strobe_safe() if not safety["safe"]: return {"status": "error", "message": safety["reason"]} + if refusal := self._testing_tool_refusal(): + return refusal result = await self.pitrac_manager.restart() logger.info(f"PiTrac restart request: {result}") return result @@ -1551,6 +1555,12 @@ async def _detect_cameras_if_unset(self) -> None: logger.warning(f"Detected {key} = {value} is not a supported option; keeping the default") logger.info(f"Saved detected settings for {len(cameras)} camera(s)") + def _testing_tool_refusal(self) -> Optional[Dict[str, Any]]: + running = self.testing_manager.get_running_tools() + if not running: + return None + return {"status": "error", "message": f"Testing tool {running[0]} is running. Stop it before starting PiTrac."} + async def _run_tool_async(self, tool_id: str) -> None: """Helper method to run a testing tool asynchronously""" try: diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index 0571658d..e7d740fa 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -136,8 +136,10 @@ async def run_tool(self, tool_id: str) -> Dict[str, Any]: if tool_id not in self.tools: return {"status": "error", "message": f"Unknown tool: {tool_id}"} - if tool_id in self.running_processes: - return {"status": "error", "message": f"Tool {tool_id} is already running"} + # One tool at a time: they share config_manager.transient_overrides + running = next(iter(self.running_processes), None) + if running: + return {"status": "error", "message": f"Tool {running} is already running"} tool_info = self.tools[tool_id] diff --git a/Software/web-server/tests/test_api_endpoints_pitrac.py b/Software/web-server/tests/test_api_endpoints_pitrac.py index acbd8d16..a222054f 100644 --- a/Software/web-server/tests/test_api_endpoints_pitrac.py +++ b/Software/web-server/tests/test_api_endpoints_pitrac.py @@ -51,6 +51,18 @@ async def test_restart_pitrac(self, client, server_instance): data = response.json() assert data["status"] in ["restarted", "started"] + @pytest.mark.parametrize("action", ["start", "restart"]) + def test_start_refused_while_testing_tool_runs(self, client, server_instance, action): + server_instance.testing_manager.running_processes["pulse_test"] = MagicMock() + launch = AsyncMock() + setattr(server_instance.pitrac_manager, action, launch) + + response = client.post(f"/api/pitrac/{action}") + + assert response.json()["status"] == "error" + assert "pulse_test" in response.json()["message"] + launch.assert_not_called() + def test_pitrac_status(self, client, server_instance): """Test getting PiTrac process status""" server_instance.pitrac_manager.get_status = MagicMock( diff --git a/Software/web-server/tests/test_testing_tools_manager.py b/Software/web-server/tests/test_testing_tools_manager.py index 5a03483c..121fa92c 100644 --- a/Software/web-server/tests/test_testing_tools_manager.py +++ b/Software/web-server/tests/test_testing_tools_manager.py @@ -112,6 +112,18 @@ async def test_run_tool_already_running(self, testing_manager): assert result["status"] == "error" assert "already running" in result["message"] + @pytest.mark.asyncio + async def test_run_tool_refused_while_other_tool_running(self, testing_manager, mock_config_manager): + testing_manager.running_processes["pulse_test"] = MagicMock() + mock_config_manager.transient_overrides = {"logging": {"level": "trace"}} + + with patch("asyncio.create_subprocess_exec") as create: + result = await testing_manager.run_tool("camera1_still") + + assert result == {"status": "error", "message": "Tool pulse_test is already running"} + create.assert_not_called() + assert mock_config_manager.transient_overrides == {"logging": {"level": "trace"}} + @pytest.mark.asyncio async def test_run_tool_success(self, testing_manager, mock_config_manager): """Test successfully running a tool""" From aae576d293764046e1d4fd00cc943678b7fc105f Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 22 Jul 2026 21:32:30 -0400 Subject: [PATCH 062/156] notify callbacks on reset and import, harden the legacy json import --- Software/web-server/config_manager.py | 51 ++++++++++++++---- .../web-server/tests/test_config_manager.py | 53 ++++++++++++++++++- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index e91d2f21..fa7dceee 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -63,6 +63,19 @@ def _flatten(nested: Dict[str, Any], prefix: str = "") -> Dict[str, Any]: return flat +def _without_dotted_keys(nested: Dict[str, Any], dropped: List[str], prefix: str = "") -> Dict[str, Any]: + clean = {} + for key, value in nested.items(): + full_key = f"{prefix}.{key}" if prefix else key + if "." in key: + dropped.append(full_key) + elif isinstance(value, dict): + clean[key] = _without_dotted_keys(value, dropped, full_key) + else: + clean[key] = value + return clean + + def _unflatten(flat: Dict[str, Any]) -> Dict[str, Any]: nested: Dict[str, Any] = {} for key, value in flat.items(): @@ -170,22 +183,28 @@ def _import_json(self, repo: KeyValueRepository, path: Path) -> None: logger.warning(f"Not importing {path}: it is not a readable JSON object, keeping the stored values") return - # get_config never resolved dotted top-level keys, so importing them would change behavior - dotted = [key for key in data if "." in key] + # get_config never resolved dotted keys, so importing them would change behavior + dotted: List[str] = [] + data = _without_dotted_keys(data, dotted) if dotted: - logger.info(f"Dropping dotted top-level keys from {path}: {', '.join(dotted)}") - data = {key: value for key, value in data.items() if "." not in key} + logger.info(f"Dropping dotted keys from {path}: {', '.join(dotted)}") repo.replace_all(_flatten(_deep_merge(_unflatten(repo.load()), data))) target = path.with_name(path.name + ".imported") if target.exists(): - target = path.with_name(f"{target.name}.{datetime.now():%Y%m%d-%H%M%S}") + stamped = f"{target.name}.{datetime.now():%Y%m%d-%H%M%S}" + target = path.with_name(stamped) + n = 0 + while target.exists(): + n += 1 + target = path.with_name(f"{stamped}.{n}") try: path.rename(target) except OSError as e: - logger.error(f"Imported {path} but could not rename it to {target}: {e}") - raise + # The rows are written and a re-import is idempotent, so failing here would only crash-loop the service + logger.error(f"Imported {path} but could not rename it to {target}, will import it again next start: {e}") + return logger.info(f"Imported {path} into the {repo.table} table, renamed to {target.name}") def _build_config_from_metadata(self) -> Dict[str, Any]: @@ -266,6 +285,12 @@ def _notify_callbacks(self, key: str, value: Any) -> None: except Exception as e: logger.error(f"Callback error for {key}: {e}", exc_info=True) + def _notify_changed(self, before: Dict[str, Any], after: Dict[str, Any]) -> None: + before, after = _flatten(before), _flatten(after) + for key in sorted(before.keys() | after.keys()): + if before.get(key) != after.get(key): + self._notify_callbacks(key, after.get(key)) + def get_config(self, key: Optional[str] = None) -> Any: """Get configuration value or entire config @@ -491,10 +516,13 @@ def _cleanup_empty_dicts(self, d: Dict[str, Any], max_depth: int = 100, current_ def reset_all(self) -> Tuple[bool, str]: """Reset all user settings to defaults""" with self._lock: + before = self.merged_config self._settings.replace_all({}) self.user_settings = {} self._rebuild_merged_config() - return True, "Reset all settings to defaults" + after = self.merged_config + self._notify_changed(before, after) + return True, "Reset all settings to defaults" def get_diff(self) -> Dict[str, Any]: """Get differences between user settings and defaults @@ -845,10 +873,13 @@ def import_config(self, import_data: Dict[str, Any]) -> Tuple[bool, str]: if new_cal is not None: self.calibration_data = new_cal + before = self.merged_config self._rebuild_merged_config() - - return True, "Configuration imported successfully" + after = self.merged_config except Exception as e: logger.error(f"Error importing configuration: {e}") return False, f"Import failed: {e}" + + self._notify_changed(before, after) + return True, "Configuration imported successfully" diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index 9ba261f1..b2b758a4 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -3,6 +3,7 @@ import json import os import pytest +from datetime import datetime from pathlib import Path from unittest.mock import patch @@ -258,6 +259,56 @@ def test_import_drops_dotted_top_level_keys(self, config_manager, db): assert KeyValueRepository(db, "settings").load() == {"cameras.slot1.type": "5"} assert config_manager.get_config("cameras.slot1.type") == "5" + def test_backups_from_the_same_second_are_all_kept(self, config_manager, tmp_path): + stamp = "user_settings.json.imported.20260101-120000" + (tmp_path / "user_settings.json.imported").write_text("first") + (tmp_path / stamp).write_text("second") + config_manager.user_settings_path.write_text("{}") + + with patch("config_manager.datetime") as clock: + clock.now.return_value = datetime(2026, 1, 1, 12, 0, 0) + config_manager.reload() + + assert (tmp_path / "user_settings.json.imported").read_text() == "first" + assert (tmp_path / stamp).read_text() == "second" + assert (tmp_path / f"{stamp}.1").read_text() == "{}" + + def test_failed_rename_keeps_imported_rows_and_starts(self, config_manager, db): + config_manager.user_settings_path.write_text(json.dumps({"cameras": {"slot1": {"type": "5"}}})) + + with patch.object(Path, "rename", side_effect=OSError("read-only")): + config_manager.reload() + + assert KeyValueRepository(db, "settings").load() == {"cameras.slot1.type": "5"} + assert config_manager.get_config("cameras.slot1.type") == "5" + + def test_import_drops_nested_dotted_keys(self, config_manager, db): + config_manager.user_settings_path.write_text(json.dumps({ + "gs_config": {"cameras": {"kCamera1Gain": "2.5"}, "a.b": 1}, + })) + + config_manager.reload() + + assert KeyValueRepository(db, "settings").load() == {"gs_config.cameras.kCamera1Gain": "2.5"} + + def test_reset_all_notifies_changed_keys(self, config_manager): + config_manager.set_config("simulators.ogs.host", "10.0.0.9") + seen = [] + config_manager.register_callback("simulators.", lambda k, v: seen.append((k, v))) + + config_manager.reset_all() + + assert seen == [("simulators.ogs.host", config_manager.get_default("simulators.ogs.host"))] + + def test_import_config_notifies_changed_keys(self, config_manager): + seen = [] + config_manager.register_callback("simulators.", lambda k, v: seen.append((k, v))) + + ok, _ = config_manager.import_config({"user_settings": {"simulators": {"ogs": {"host": "10.0.0.9"}}}}) + + assert ok + assert seen == [("simulators.ogs.host", "10.0.0.9")] + def test_import_config_failure_leaves_settings_untouched(self, config_manager, db): config_manager.set_config("gs_config.cameras.kCamera1Gain", "3.5") @@ -713,7 +764,7 @@ def test_build_generated_config_merges_transient_overrides(self, config_manager) config_manager.transient_overrides = {} cfg = config_manager.build_generated_config() - assert cfg["gs_config"].get("testing", {}).get("kBaseTestImageDir") != "/tmp/x/" + assert cfg["gs_config"]["testing"]["kBaseTestImageDir"] == "./Images/" def test_build_generated_config_includes_former_cli_and_env(self, config_manager): cfg = config_manager.build_generated_config() From bd4474d4a44dc0ea8b47f2a0da11ea9807294682 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 23 Jul 2026 00:16:32 -0400 Subject: [PATCH 063/156] prune whole shot dirs by id instead of trusting stored file paths --- Software/web-server/retention.py | 32 +++++++-------------- Software/web-server/tests/test_retention.py | 26 +++++++++++++++++ 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/Software/web-server/retention.py b/Software/web-server/retention.py index cb1c1e9e..e70aba20 100644 --- a/Software/web-server/retention.py +++ b/Software/web-server/retention.py @@ -37,10 +37,8 @@ def prune(self, now: datetime | None = None) -> None: if session is None: break # only open sessions or image-less sessions remain; stop sid = session["id"] - for rel in self._shots.image_paths_for_session(sid): - (self._images_dir / rel).unlink(missing_ok=True) - # Remove emptied per-shot dirs - self._rmdir_if_empty_for_session(sid) + for shot_id in self._shots.shot_ids_for_session(sid): + self._remove_shot_dir(shot_id) self._shots.delete_images_for_session(sid) total = _dir_bytes(shots_dir) logger.info(f"pruned session {sid} images; remaining {total // (1024*1024)}MB") @@ -50,12 +48,7 @@ def prune(self, now: datetime | None = None) -> None: shot_id = self._shots.oldest_shot_with_images_excluding_latest() if shot_id is None: break - for img in self._shots.get(shot_id)["images"]: - (self._images_dir / img["file_path"]).unlink(missing_ok=True) - try: - (shots_dir / str(shot_id)).rmdir() - except OSError: - pass + self._remove_shot_dir(shot_id) self._shots.delete_images_for_shot(shot_id) total = _dir_bytes(shots_dir) logger.info(f"pruned shot {shot_id} images; remaining {total // (1024*1024)}MB") @@ -79,14 +72,11 @@ def prune(self, now: datetime | None = None) -> None: shutil.rmtree(d, ignore_errors=True) logger.info(f"pruned orphan dir {d.name}") - def _rmdir_if_empty_for_session(self, session_id: int) -> None: - """Remove shots/ dirs that are now empty for a given session's shots.""" - shot_ids = self._shots.shot_ids_for_session(session_id) - shots_dir = self._images_dir / "shots" - for sid in shot_ids: - d = shots_dir / str(sid) - if d.is_dir(): - try: - d.rmdir() # only removes if empty - except OSError: - pass # non-empty or already gone; leave it + def _remove_shot_dir(self, shot_id: int) -> None: + # Deletes by shot id, never by shot_images.file_path, so a stored path cannot point the delete elsewhere. + shots_dir = (self._images_dir / "shots").resolve() + d = shots_dir / str(shot_id) + if d.resolve() != d: + logger.warning(f"not pruning {d}: it resolves outside {shots_dir}") + return + shutil.rmtree(d, ignore_errors=True) diff --git a/Software/web-server/tests/test_retention.py b/Software/web-server/tests/test_retention.py index 5956e154..2ca984e4 100644 --- a/Software/web-server/tests/test_retention.py +++ b/Software/web-server/tests/test_retention.py @@ -209,6 +209,32 @@ def test_shot_rows_survive_pruning(self, session_repo, shot_repo, images_dir): # images column is empty (rows deleted) but shot exists assert shot["images"] == [] + def test_pruned_session_removes_unknown_files_in_shot_dir(self, session_repo, shot_repo, images_dir): + _, fpath = _make_session(session_repo, shot_repo, images_dir, shot_id=1, size_bytes=600 * 1024) + _write_file(fpath.parent / "extra.bin") + session_repo.close_open("2026-06-01T10:30:00") + + make_retention(session_repo, shot_repo, images_dir, cap_mb=0).prune() + + assert not fpath.parent.exists() + + def test_open_session_prune_ignores_row_paths_outside_shots(self, session_repo, shot_repo, images_dir, tmp_path): + sid = session_repo.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) + outside = tmp_path / "x" + _write_file(outside) + _write_file(images_dir / "shots/1/spin1.png", 600 * 1024) + _write_file(images_dir / "shots/1/extra.bin") + shot_repo.add(1, sid, _hit(), [("spin1", "shots/1/spin1.png"), ("x", "../x")]) + _make_session(session_repo, shot_repo, images_dir, shot_id=2, size_bytes=100) + + r = make_retention(session_repo, shot_repo, images_dir, cap_mb=0) + r._cap_bytes = 1024 + r.prune() + + assert outside.exists() + assert not (images_dir / "shots" / "1").exists() + assert shot_repo.get(1)["images"] == [] + def test_empty_shots_dir_counts_as_zero(self, session_repo, shot_repo, images_dir): # no shots dir at all → noop, no crash r = make_retention(session_repo, shot_repo, images_dir, cap_mb=1) From d178fbcc0f812cbc58dbf50aaabf61705a6a4f6a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 23 Jul 2026 11:51:10 -0400 Subject: [PATCH 064/156] time out ogs connects, clean up a cancelled connect, ignore sim config writes after stop --- Software/web-server/sim_manager.py | 1 + Software/web-server/sims/ogs_sim.py | 15 +++++-- Software/web-server/tests/test_ogs_sim.py | 44 +++++++++++++++++++ Software/web-server/tests/test_sim_manager.py | 13 ++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index 134c178d..75962d76 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -43,6 +43,7 @@ async def start(self) -> None: await self._broadcast_status() async def stop(self) -> None: + self.loop = None if self._reload_task is not None: self._reload_task.cancel() self._reload_task = None diff --git a/Software/web-server/sims/ogs_sim.py b/Software/web-server/sims/ogs_sim.py index e6f5b4f2..c26acd42 100644 --- a/Software/web-server/sims/ogs_sim.py +++ b/Software/web-server/sims/ogs_sim.py @@ -45,6 +45,7 @@ def build_shot_payload(shot: ShotData) -> Dict[str, object]: class OGSSim(SimInterface): name = "ogs" display_name = "OpenGolfSim" + CONNECT_TIMEOUT_SEC = 5 def __init__(self, host: str, port: int = 3111, keepalive_sec: int = 5) -> None: super().__init__() @@ -76,13 +77,19 @@ async def _open(self) -> None: return await self._set_status(STATUS_CONNECTING, f"{self.host}:{self.port}") try: - self._reader, self._writer = await asyncio.open_connection(self.host, self.port) + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), self.CONNECT_TIMEOUT_SEC + ) + await self._send_obj({"type": "device", "status": "ready"}) + except asyncio.CancelledError: + self._teardown_socket() + await self._set_status(STATUS_OFF, "") + raise except Exception as e: - logger.warning(f"OGS connect failed: {e}") - await self._set_status(STATUS_ERROR, str(e)) + logger.warning(f"OGS connect failed: {e!r}") + await self._set_status(STATUS_ERROR, str(e) or type(e).__name__) self._schedule_reconnect() return - await self._send_obj({"type": "device", "status": "ready"}) await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") self._keepalive_task = asyncio.create_task(self._keepalive_loop()) diff --git a/Software/web-server/tests/test_ogs_sim.py b/Software/web-server/tests/test_ogs_sim.py index 60a2f287..17b578d2 100644 --- a/Software/web-server/tests/test_ogs_sim.py +++ b/Software/web-server/tests/test_ogs_sim.py @@ -1,5 +1,6 @@ import asyncio import json +from unittest.mock import MagicMock import pytest @@ -107,3 +108,46 @@ async def test_connect_failure_sets_error_status(): await asyncio.sleep(0.05) assert sim.status in ("error", "connecting") await sim.disconnect() + + +@pytest.mark.asyncio +async def test_connect_timeout_is_a_connect_failure(monkeypatch): + async def never_connects(host, port): + await asyncio.Event().wait() + + monkeypatch.setattr(asyncio, "open_connection", never_connects) + sim = OGSSim(host="10.255.255.1", port=3111, keepalive_sec=999) + sim.CONNECT_TIMEOUT_SEC = 0.05 + + await asyncio.wait_for(sim.connect(), 1) + + assert sim.status == "error" + assert sim._writer is None + assert sim._reconnect_task is not None + await sim.disconnect() + + +@pytest.mark.asyncio +async def test_cancel_during_first_send_closes_socket(monkeypatch): + async def stalled_drain(): + await asyncio.Event().wait() + + writer = MagicMock() + writer.drain = stalled_drain + + async def opens(host, port): + return MagicMock(), writer + + monkeypatch.setattr(asyncio, "open_connection", opens) + sim = OGSSim(host="127.0.0.1", port=3111, keepalive_sec=999) + task = asyncio.create_task(sim.connect()) + while not writer.write.called: + await asyncio.sleep(0) + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + writer.close.assert_called_once() + assert sim._writer is None + assert sim.status == "off" diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index d098242d..2e03daf1 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -148,3 +148,16 @@ async def test_stop_cancels_pending_reload(monkeypatch): await mgr.stop() await asyncio.sleep(0.15) assert builds == [] + + +@pytest.mark.asyncio +async def test_change_after_stop_does_not_reload(monkeypatch): + monkeypatch.setattr(SimManager, "RELOAD_DEBOUNCE_SEC", 0.01) + mgr = SimManager(_ogs_config(True), broadcast=None) + mgr.loop = asyncio.get_running_loop() + builds = [] + mgr.build_sims = lambda: builds.append(1) + await mgr.stop() + mgr._on_config_change("simulators.ogs.host", "x") + await asyncio.sleep(0.05) + assert builds == [] From ca3db33e8b9634cc4e3059f3ce6be75747ffbb5a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 24 Jul 2026 17:29:24 -0400 Subject: [PATCH 065/156] keep the web-server venv across build.sh dev syncs --- packaging/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/build.sh b/packaging/build.sh index f55f48c8..5d180d5a 100755 --- a/packaging/build.sh +++ b/packaging/build.sh @@ -671,7 +671,7 @@ SUDOEOF update_web_server() { log_info "Syncing web server files..." mkdir -p /usr/lib/pitrac/web-server - rsync -a --checksum --delete \ + rsync -a --checksum --delete --exclude .venv \ "$WEB_SERVER_DIR"/ /usr/lib/pitrac/web-server/ install_python_dependencies "/usr/lib/pitrac/web-server" From 387fcf61f1f8755bdc8774b938a6964c6651c005 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 24 Jul 2026 19:23:15 -0400 Subject: [PATCH 066/156] reserve the testing tool slot before spawning, delete session images by shot dir --- Software/web-server/retention.py | 6 +++--- Software/web-server/server.py | 21 +++---------------- Software/web-server/testing_tools_manager.py | 13 +++++++----- Software/web-server/tests/test_history_api.py | 18 ++++++++++++++++ .../tests/test_testing_tools_manager.py | 21 +++++++++++++++++++ 5 files changed, 53 insertions(+), 26 deletions(-) diff --git a/Software/web-server/retention.py b/Software/web-server/retention.py index e70aba20..1c61103a 100644 --- a/Software/web-server/retention.py +++ b/Software/web-server/retention.py @@ -38,7 +38,7 @@ def prune(self, now: datetime | None = None) -> None: break # only open sessions or image-less sessions remain; stop sid = session["id"] for shot_id in self._shots.shot_ids_for_session(sid): - self._remove_shot_dir(shot_id) + self.remove_shot_dir(shot_id) self._shots.delete_images_for_session(sid) total = _dir_bytes(shots_dir) logger.info(f"pruned session {sid} images; remaining {total // (1024*1024)}MB") @@ -48,7 +48,7 @@ def prune(self, now: datetime | None = None) -> None: shot_id = self._shots.oldest_shot_with_images_excluding_latest() if shot_id is None: break - self._remove_shot_dir(shot_id) + self.remove_shot_dir(shot_id) self._shots.delete_images_for_shot(shot_id) total = _dir_bytes(shots_dir) logger.info(f"pruned shot {shot_id} images; remaining {total // (1024*1024)}MB") @@ -72,7 +72,7 @@ def prune(self, now: datetime | None = None) -> None: shutil.rmtree(d, ignore_errors=True) logger.info(f"pruned orphan dir {d.name}") - def _remove_shot_dir(self, shot_id: int) -> None: + def remove_shot_dir(self, shot_id: int) -> None: # Deletes by shot id, never by shot_images.file_path, so a stored path cannot point the delete elsewhere. shots_dir = (self._images_dir / "shots").resolve() d = shots_dir / str(shot_id) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 1585e709..c29bbfff 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -1193,11 +1193,10 @@ async def delete_session(session_id: int) -> Dict[str, Any]: session = await asyncio.to_thread(self.session_repo.get, session_id) if session is None: raise HTTPException(status_code=404, detail="Session not found") - image_paths = await asyncio.to_thread( - self.shot_repo.image_paths_for_session, session_id - ) + shot_ids = await asyncio.to_thread(self.shot_repo.shot_ids_for_session, session_id) await asyncio.to_thread(self.session_repo.delete, session_id) - await asyncio.to_thread(self._delete_session_files, image_paths) + for shot_id in shot_ids: + await asyncio.to_thread(self.retention.remove_shot_dir, shot_id) return {"status": "deleted", "session_id": session_id} @self.app.get("/api/storage/usage") @@ -1581,20 +1580,6 @@ def _persist_shot(self, shot_id, shot_data, images): session_id = self.session_repo.ensure_open(shot_data.timestamp, self.session_timeout_minutes) self.shot_repo.add(shot_id, session_id, shot_data, images) - def _delete_session_files(self, image_paths: List[str]) -> None: - """Unlink image files and remove emptied per-shot dirs.""" - parent_dirs: set[Path] = set() - for rel in image_paths: - p = IMAGES_DIR / rel - p.unlink(missing_ok=True) - parent_dirs.add(p.parent) - for d in parent_dirs: - if d.is_dir(): - try: - d.rmdir() - except OSError: - pass - async def shutdown_event(self) -> None: logger.info("Shutting down PiTrac Web Server...") diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index e7d740fa..f518ccd5 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -140,6 +140,8 @@ async def run_tool(self, tool_id: str) -> Dict[str, Any]: running = next(iter(self.running_processes), None) if running: return {"status": "error", "message": f"Tool {running} is already running"} + # Reserved with no await since the check, so a run arriving during the spawn sees it + self.running_processes[tool_id] = None tool_info = self.tools[tool_id] @@ -273,14 +275,13 @@ async def run_tool(self, tool_id: str) -> Dict[str, Any]: "status": "timeout", "message": f"Tool {tool_id} timed out after {tool_info['timeout']} seconds", } - finally: - self.running_processes.pop(tool_id, None) - self.config_manager.transient_overrides = {} except Exception as e: - self.config_manager.transient_overrides = {} logger.error(f"Error running tool {tool_id}: {e}") return {"status": "error", "message": str(e)} + finally: + self.running_processes.pop(tool_id, None) + self.config_manager.transient_overrides = {} async def stop_tool(self, tool_id: str) -> Dict[str, Any]: """Stop a running tool @@ -293,9 +294,11 @@ async def stop_tool(self, tool_id: str) -> Dict[str, Any]: """ if tool_id not in self.running_processes: return {"status": "error", "message": f"Tool {tool_id} is not running"} + process = self.running_processes[tool_id] + if process is None: + return {"status": "error", "message": f"Tool {tool_id} is still starting"} try: - process = self.running_processes[tool_id] process.terminate() try: diff --git a/Software/web-server/tests/test_history_api.py b/Software/web-server/tests/test_history_api.py index 0b9e8b3f..7ebbba83 100644 --- a/Software/web-server/tests/test_history_api.py +++ b/Software/web-server/tests/test_history_api.py @@ -154,6 +154,24 @@ def test_delete_removes_image_files(self, history_client, history_server, tmp_pa # parent dir also removed since it's now empty assert not shots_dir.exists() + def test_delete_removes_shot_dir_by_id_not_stored_path(self, history_client, history_server, tmp_path): + shot_id = 4003 + shot_dir = tmp_path / "images" / "shots" / str(shot_id) + shot_dir.mkdir(parents=True) + (shot_dir / "spin1.png").write_bytes(b"fake") + (shot_dir / "extra.bin").write_bytes(b"fake") + outside = tmp_path / "outside.png" + outside.write_bytes(b"keep") + payload = make_hit_payload(shot_id) + payload["images"] = [f"shots/{shot_id}/spin1.png", "../outside.png"] + + history_client.post("/api/internal/shot-result", json=payload) + session_id = history_server.session_repo.list()[0]["id"] + history_client.delete(f"/api/sessions/{session_id}") + + assert not shot_dir.exists() + assert outside.exists() + def test_delete_unknown_session_returns_404(self, history_client): resp = history_client.delete("/api/sessions/99999") assert resp.status_code == 404 diff --git a/Software/web-server/tests/test_testing_tools_manager.py b/Software/web-server/tests/test_testing_tools_manager.py index 121fa92c..cb33a736 100644 --- a/Software/web-server/tests/test_testing_tools_manager.py +++ b/Software/web-server/tests/test_testing_tools_manager.py @@ -124,6 +124,27 @@ async def test_run_tool_refused_while_other_tool_running(self, testing_manager, create.assert_not_called() assert mock_config_manager.transient_overrides == {"logging": {"level": "trace"}} + @pytest.mark.asyncio + async def test_run_tool_refused_while_another_is_spawning(self, testing_manager): + spawning = asyncio.Event() + release = asyncio.Event() + + async def slow_spawn(*args, **kwargs): + spawning.set() + await release.wait() + raise OSError("spawn failed") + + with patch("asyncio.create_subprocess_exec", side_effect=slow_spawn): + first = asyncio.create_task(testing_manager.run_tool("pulse_test")) + await spawning.wait() + second = await asyncio.wait_for(testing_manager.run_tool("camera1_still"), 1) + release.set() + first_result = await first + + assert second == {"status": "error", "message": "Tool pulse_test is already running"} + assert first_result == {"status": "error", "message": "spawn failed"} + assert testing_manager.running_processes == {} + @pytest.mark.asyncio async def test_run_tool_success(self, testing_manager, mock_config_manager): """Test successfully running a tool""" From 47e7fa8ad07db8a594f6dd7902237add8c7598ec Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 27 Jul 2026 19:35:09 -0400 Subject: [PATCH 067/156] let the stopped run release its own slot, drop unused image_paths_for_session --- Software/web-server/db/repositories.py | 8 ----- Software/web-server/testing_tools_manager.py | 3 +- .../web-server/tests/test_repositories.py | 6 ---- Software/web-server/tests/test_retention.py | 16 ++++----- .../tests/test_testing_tools_manager.py | 36 ++++++++++++++++++- 5 files changed, 44 insertions(+), 25 deletions(-) diff --git a/Software/web-server/db/repositories.py b/Software/web-server/db/repositories.py index dc7139d9..ea5e7a7b 100644 --- a/Software/web-server/db/repositories.py +++ b/Software/web-server/db/repositories.py @@ -97,14 +97,6 @@ def list_for_session(self, session_id: int) -> List[Dict[str, Any]]: ) return [self._attach_images(dict(r)) for r in rows] - def image_paths_for_session(self, session_id: int) -> List[str]: - rows = self.db.query( - """SELECT si.file_path FROM shot_images si - JOIN shots s ON s.id = si.shot_id WHERE s.session_id = ?""", - (session_id,), - ) - return [r["file_path"] for r in rows] - def delete_images_for_session(self, session_id: int) -> None: self.db.execute( "DELETE FROM shot_images WHERE shot_id IN (SELECT id FROM shots WHERE session_id = ?)", diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index f518ccd5..abdaaad9 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -307,8 +307,7 @@ async def stop_tool(self, tool_id: str) -> Dict[str, Any]: process.kill() await process.wait() - del self.running_processes[tool_id] - + # run_tool's finally releases the slot and the overrides once its cleanup is done return {"status": "success", "message": f"Tool {tool_id} stopped"} except Exception as e: diff --git a/Software/web-server/tests/test_repositories.py b/Software/web-server/tests/test_repositories.py index 2d4d1167..0fbe3177 100644 --- a/Software/web-server/tests/test_repositories.py +++ b/Software/web-server/tests/test_repositories.py @@ -119,12 +119,6 @@ def test_get_returns_shot_with_images(self, sessions, shots): assert shot["images"] == [{"kind": "spin1", "file_path": "a.png"}] assert shots.get(999) is None - def test_image_paths_for_session(self, sessions, shots): - s = sessions.ensure_open("2026-06-01T10:00:00", timeout_minutes=30) - shots.add(1, s, hit(), [("spin1", "a.png"), ("spin2", "b.png")]) - paths = shots.image_paths_for_session(s) - assert set(paths) == {"a.png", "b.png"} - def test_delete_images_for_session_removes_only_that_session(self, sessions, shots, db): s1 = sessions.ensure_open("2026-06-01T09:00:00", timeout_minutes=30) shots.add(1, s1, hit(), [("spin1", "s1.png")]) diff --git a/Software/web-server/tests/test_retention.py b/Software/web-server/tests/test_retention.py index 2ca984e4..16372ebd 100644 --- a/Software/web-server/tests/test_retention.py +++ b/Software/web-server/tests/test_retention.py @@ -76,15 +76,15 @@ def make_retention(session_repo, shot_repo, images_dir, cap_mb): class TestImageRetention: def test_under_cap_is_noop(self, session_repo, shot_repo, images_dir): - sid, fpath = _make_session(session_repo, shot_repo, images_dir, - shot_id=1, size_bytes=100) + _, fpath = _make_session(session_repo, shot_repo, images_dir, + shot_id=1, size_bytes=100) session_repo.close_open("2026-06-01T10:30:00") r = make_retention(session_repo, shot_repo, images_dir, cap_mb=500) r.prune() assert fpath.exists() - assert len(shot_repo.image_paths_for_session(sid)) == 1 + assert len(shot_repo.get(1)["images"]) == 1 def test_prunes_oldest_ended_session_first(self, session_repo, shot_repo, images_dir): # session 1 (older) — 600 KB @@ -106,21 +106,21 @@ def test_prunes_oldest_ended_session_first(self, session_repo, shot_repo, images assert not f1.exists(), "oldest session image should be pruned" assert f2.exists(), "newer session image should survive" - assert shot_repo.image_paths_for_session(sid1) == [] - assert len(shot_repo.image_paths_for_session(sid2)) == 1 + assert shot_repo.get(1)["images"] == [] + assert len(shot_repo.get(2)["images"]) == 1 # shot ROW for sid1 still present — rows are never deleted assert shot_repo.get(1) is not None def test_open_session_never_pruned(self, session_repo, shot_repo, images_dir): - sid, fpath = _make_session(session_repo, shot_repo, images_dir, - shot_id=10, size_bytes=600 * 1024) + _, fpath = _make_session(session_repo, shot_repo, images_dir, + shot_id=10, size_bytes=600 * 1024) # session is still OPEN r = make_retention(session_repo, shot_repo, images_dir, cap_mb=0) r.prune() assert fpath.exists(), "open session image must survive" - assert len(shot_repo.image_paths_for_session(sid)) == 1 + assert len(shot_repo.get(10)["images"]) == 1 def test_terminates_when_only_unprunable_remains(self, session_repo, shot_repo, images_dir): # open session only; over cap → prune should return cleanly diff --git a/Software/web-server/tests/test_testing_tools_manager.py b/Software/web-server/tests/test_testing_tools_manager.py index cb33a736..2a6ad623 100644 --- a/Software/web-server/tests/test_testing_tools_manager.py +++ b/Software/web-server/tests/test_testing_tools_manager.py @@ -315,7 +315,41 @@ async def test_stop_tool_success(self, testing_manager): assert result["status"] == "success" assert "stopped" in result["message"] mock_process.terminate.assert_called_once() - assert "pulse_test" not in testing_manager.running_processes + assert testing_manager.running_processes["pulse_test"] is mock_process + + @pytest.mark.asyncio + async def test_run_refused_until_stopped_run_cleans_up(self, testing_manager, mock_config_manager): + exited = asyncio.Event() + log_read = asyncio.Event() + process = Mock(returncode=-15) + process.terminate = Mock(side_effect=exited.set) + + async def communicate(): + await exited.wait() + return b"", b"" + + async def wait(): + await exited.wait() + + async def slow_log_read(start_time): + await log_read.wait() + + process.communicate = communicate + process.wait = wait + + with patch("asyncio.create_subprocess_exec", return_value=process): + with patch.object(testing_manager, "_find_and_read_test_log", side_effect=slow_log_read): + first = asyncio.create_task(testing_manager.run_tool("pulse_test")) + while testing_manager.running_processes.get("pulse_test") is not process: + await asyncio.sleep(0) + await testing_manager.stop_tool("pulse_test") + second = await asyncio.wait_for(testing_manager.run_tool("camera1_still"), 1) + log_read.set() + await first + + assert second == {"status": "error", "message": "Tool pulse_test is already running"} + assert testing_manager.running_processes == {} + assert mock_config_manager.transient_overrides == {} @pytest.mark.asyncio async def test_stop_tool_kill_on_timeout(self, testing_manager): From d7bfc92cbcccb9ee6e069c9a41061048f97bf24f Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 28 Jul 2026 22:14:29 -0400 Subject: [PATCH 068/156] drop dead backup files, stale slot env vars, and redundant focal length guards --- .../ImageProcessing.vcxproj.BACKUP | 544 ------ .../ImageProcessing.vcxproj.user | 6 - .../ImageProcessing/camera_hardware.cpp | 10 +- .../ImageProcessing/gs_test.cpp.B4_NEW_TEST | 1538 ----------------- 4 files changed, 2 insertions(+), 2096 deletions(-) delete mode 100644 Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.BACKUP delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_test.cpp.B4_NEW_TEST diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.BACKUP b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.BACKUP deleted file mode 100644 index 3c8437cc..00000000 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.BACKUP +++ /dev/null @@ -1,544 +0,0 @@ - - - - - - Debug - ARM - - - Debug - Win32 - - - Release - ARM - - - Release - Win32 - - - Debug - x64 - - - Release - x64 - - - - 16.0 - Win32Proj - {3aa11590-5347-47e5-837c-1d38ea9a56f3} - ImageProcessing - 10.0 - - - - Application - true - v145 - Unicode - - - Application - false - v145 - true - Unicode - - - Application - true - v145 - Unicode - - - Application - true - v145 - Unicode - - - Application - false - v145 - true - Unicode - - - Application - false - v145 - true - Unicode - - - - - - - - - - - - - - - - - - - - - - - - - - - - Level3 - true - WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - - - Console - true - - - - - Level3 - true - true - true - WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - - - Console - true - true - true - true - - - - - Level3 - true - _CRT_SECURE_NO_WARNINGS;BOOST_BIND_GLOBAL_PLACEHOLDERS;BOOST_ALL_DYN_LINK;BOOST_USE_WINAPI_VERSION=0x0A00;YAML_CPP_STATIC_DEFINE;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\yaml-cpp\include;..\packages\Microsoft.ML.OnnxRuntime.1.23.2\build\native\include;E:\Dev_Libs\boost_1_90_0\boost - stdcpp20 - OldStyle - - - Console - true - E:\Dev_Libs\opencv\build\x64\vc16\lib;E:\Dev_Libs\boost_1_90_0\lib64-msvc-14.3 - E:\Dev_Libs\opencv\build\x64\vc16\lib\opencv_world4120d.lib;E:\dev_libs\yaml-cpp\build\Debug\yaml-cppd.lib;..\packages\Microsoft.ML.OnnxRuntime.1.23.2\runtimes\win-x64\native\onnxruntime.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) - - - - - Level3 - true - _DEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\boost_1_81_0\;E:\Dev_Libs\NumCpp-master\include; - stdcpp20 - OldStyle - - - Console - true - E:\Dev_Libs\opencv\build\x64\vc16\lib;E:\Dev_Libs\boost_1_81_0\stage\lib - opencv_world490d.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) - - - - - Level3 - true - true - true - NDEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\boost_1_81_0\;E:\Dev_Libs\boost_1_81_0\boost;E:\Dev_Libs\NumCpp-master\include - stdcpp20 - OldStyle - - - Console - true - true - true - E:\Dev_Libs\opencv\build\x64\vc16\lib\;E:\Dev_Libs\boost_1_81_0\stage\lib - opencv_world490.lib;%(AdditionalDependencies) - true - true - - - - - Level3 - true - true - true - NDEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\boost_1_81_0\;E:\Dev_Libs\boost_1_81_0\boost;E:\Dev_Libs\NumCpp-master\include - stdcpp20 - OldStyle - - - Console - true - true - true - E:\Dev_Libs\opencv\build\x64\vc16\lib\;E:\Dev_Libs\boost_1_81_0\stage\lib - opencv_world490.lib;%(AdditionalDependencies) - true - true - - - - - - - - - - - true - - - true - - - true - - - true - - - - - - - - - true - - - true - - - true - - - true - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - true - - - true - - - true - - - true - - - true - - - - - stdcpp20 - stdcpp20 - - - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - - - - - - - - - - - - true - - - - - - - - - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user index 8d4fe8b0..d9fdea2e 100755 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user @@ -4,12 +4,6 @@ --show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550 PITRAC_BASE_IMAGE_LOGGING_DIR=L:\ PATH=%PATH%;E:\Dev_Libs\opencv\build\x64\vc16\bin;E:\Dev_Libs\boost_1_90_0\lib64-msvc-14.3 -PITRAC_SLOT1_CAMERA_TYPE=5 -PITRAC_SLOT2_CAMERA_TYPE=5 -PITRAC_SLOT1_LENS_TYPE=1 -PITRAC_SLOT2_LENS_TYPE=1 -PITRAC_SLOT1_CAMERA_ORIENTATION =1 -PITRAC_SLOT2_CAMERA_ORIENTATION =1 HOME=. WindowsLocalDebugger --show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=1 --logging_level info --artifact_save_level=all --wait_keys 0 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=1 --logging_level info --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level info --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 0 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550| diff --git a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp index ca77d54b..c3cbac44 100644 --- a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp +++ b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp @@ -479,19 +479,13 @@ namespace golf_sim { // Only if we are using a custom focal length, then use that length if (lens_type == Lens_Custom) { tag = "gs_config.cameras.kSlot" + std::to_string(camera_number_) + "CustomLensFocalLength"; - if (GolfSimConfiguration::PropertyExists(tag)) { - GolfSimConfiguration::SetConstant(tag, focal_length_); - GS_LOG_TRACE_MSG(trace, "Setting custom focal length (from JSON file) = " + std::to_string(focal_length_)); - } } else { // Otherwise, for non-custom lenses, just use the standard focal length parameter tag = "gs_config.cameras.kCamera" + std::to_string(camera_number_) + "FocalLength"; - if (GolfSimConfiguration::PropertyExists(tag)) { - GolfSimConfiguration::SetConstant(tag, focal_length_); - GS_LOG_TRACE_MSG(trace, "Setting focal length (from JSON file) = " + std::to_string(focal_length_)); - } } + GolfSimConfiguration::SetConstant(tag, focal_length_); + GS_LOG_TRACE_MSG(trace, "Focal length = " + std::to_string(focal_length_)); } diff --git a/Software/LMSourceCode/ImageProcessing/gs_test.cpp.B4_NEW_TEST b/Software/LMSourceCode/ImageProcessing/gs_test.cpp.B4_NEW_TEST deleted file mode 100644 index 0d0f5576..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_test.cpp.B4_NEW_TEST +++ /dev/null @@ -1,1538 +0,0 @@ -// ImageProcessing.cpp { This file contains the main test function. Program execution begins and ends there. -// - -#include - -#include - -#include -#include -#include - -#include -#include -#include - -#include "gs_globals.h" -#include "golf_ball.h" -#include "gs_options.h" -#include "gs_gspro_results.h" -#include "gs_ui_system.h" - -#include "logging_tools.h" -#include "colorsys.h" -#include "ball_image_proc.h" -#include "cv_utils.h" -#include "gs_camera.h" -#include "gs_config.h" -#include "pulse_strobe.h" - - -#include "gs_gspro_results.h" -#include "gs_gspro_test_server.h" -#include "gs_gspro_response.h" - -#include "gs_fsm.h" -#include "gs_ipc_system.h" -#include "libcamera_interface.h" - - -namespace fs = std::filesystem; - -using namespace golf_sim; - -const double kLocationTolerancePercent = 10; - - -// The result files we create will be prefixed with this -static constexpr std::string_view TEST_IMAGE_PREFIX = "TEST_RESULT_GetBall_"; - -#ifdef __unix__ -const std::string kBaseTestDir = "/mnt/VerdantShare/dev/GolfSim/LM/Images/"; -#else -const std::string kBaseTestDir = "D:\\GolfSim\\LM\\Images\\"; -#endif - - -BallImageProc *get_image_processor() { - BallImageProc *ip = new BallImageProc; - // TBD - Setup as necessary - return ip; -} - - -void test_image(std::string_view subdir, std::string_view filename) { - BOOST_LOG_FUNCTION(); - - fs::path dir(subdir); - fs::path file(filename); - fs::path fullPath = dir / file; - std::string fname = fullPath.generic_string(); - - GolfBall ball; - - boost::to_lower(fname); - size_t i = fname.find("clr-"); - - std::string color; - if (i > 0) { - color = fname.substr(i); - } - - if (!color.empty()) { - - boost::to_lower(color); - - if (color.find("white") > 0) { - ball.ball_color_ = GolfBall::BallColor::kWhite; - } - else if (color.find("orange") > 0) { - ball.ball_color_ = GolfBall::BallColor::kOrange; - } - else if (color.find("yellow") > 0) { - ball.ball_color_ = GolfBall::BallColor::kYellow; - } - else if (color.find("green") > 0) { - ball.ball_color_ = GolfBall::BallColor::kOpticGreen; - } - else { - // For this part of our test suite, if there is no specified color, white(not kUnknown) is our best bet - ball.ball_color_ = GolfBall::BallColor::kWhite; - } - } - - BallImageProc *ip = get_image_processor(); - - cv::Mat img = cv::imread(fname, cv::IMREAD_COLOR); - ip->image_name_ = fname; - - cv::Rect nullROI; - std::vector return_balls; - bool result = ip->GetBall(img, ball, return_balls, nullROI, BallImageProc::BallSearchMode::kFindPlacedBall ); - - if (!result || return_balls.empty()) { - GS_LOG_MSG(error, "GetBall() failed to get a ball."); - return; - } - - GolfBall result_ball = return_balls.front(); - - // If there were any debugging windows, get rid of them now - cv::destroyAllWindows(); - - // Create a single output file with the result overlaid on the original image, along with - // whatever other debug images that we want. - - cv::Mat outputImage; - - auto ncFinalArray = nc::NdArray(ip->final_result_image_.data, ip->final_result_image_.rows, ip->final_result_image_.cols); - auto ncCandidatesArray = nc::NdArray(ip->candidates_image_.data, ip->candidates_image_.rows, ip->candidates_image_.cols); - auto ncOutputImage = nc::hstack({ ncFinalArray, ncCandidatesArray }); - - outputImage = CvUtils::ConvertNDArrayToMat(ncOutputImage); - - fs::path outputDir(subdir); - fs::path outputFile(std::string(TEST_IMAGE_PREFIX).append(filename)); - fs::path outputFullPath = outputDir / outputFile; - std::string outputFileName = outputFullPath.generic_string(); - - cv::imwrite(outputFileName, outputImage); - - LoggingTools::ShowImage(fname, outputImage); -} - -void test_certain_images() { - - test_image("./Images/", "FakePiCameraPhotoOfGolfBall-Clr-Green-Flat.png"); - test_image("./Images/", "WedgeNextToOrangeBall-Clr-Orange.png"); - test_image("./Images/", "FirstPiV1CamBall-Clr-Yellow.jpeg"); - test_image("./Images/", "AboutToBeHitLoRes-Clr-White.jpg"); - test_image("./Images/", "JustHitByIronToRight-Clr-White.png"); - test_image("./Images/", "JustHitSlightBlurClub-Clr-White.png"); - test_image("./Images/", "WedgeNextToOrangeBall-Clr-Orange.jpg"); - test_image("./Images/", "HitClubGoneWithFlyingTee-Clr-White.png"); - test_image("./Images/", "IMG_7713-Clr-Yellow.jpg"); -} - - - -void WalkDirectoryTree(const fs::path& pathToScan, int level = 0) { - for (const auto& entry : fs::directory_iterator(pathToScan)) { - const auto filenameStr = entry.path().filename().string(); - if (entry.is_directory() && (filenameStr.find("IGNORE") != std::string::npos) ) { - // Recurse to any sub-directories - WalkDirectoryTree(entry, level + 1); - } - else if (entry.is_regular_file()) { - if (((filenameStr.find(".png") > 0) && - (filenameStr.find(".jpg") > 0) && - (filenameStr.find(".jpeg") > 0)) && - ((filenameStr.find("IGNORE") == std::string::npos) && - (filenameStr.find("TEST_IMAGE_PREFIX") == std::string::npos)) - ) { - test_image(pathToScan.generic_string(), filenameStr); - } - } - else - std::cout << "(ignoring)" << filenameStr << "\n"; - } -} - -void test_all_test_files() { -// std::string rootdir = "D:/GolfSim/C++Code/GolfSim/ImageProcessing/Images"; - std::string rootdir = "D:/GolfSim/TestPictures"; - - - WalkDirectoryTree(rootdir); -} - -void test_calibrated_location(std::string twoFootImgName, std::string threeFootImgName, std::string fourFootImgName) { - - cv::Mat img = cv::imread(twoFootImgName); - LoggingTools::ShowImage(twoFootImgName, img); - - // Test of the GetCalibratedBall function - GolfSimCamera c; - GolfBall b; - - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - bool success = c.GetCalibratedBall(img, b); - - // Now, test the calibration by seeing if the same ball can be found using the calibrated ball - GS_LOG_MSG(trace, "GET BALL LOCATION AGAIN FOR 2 FEET"); - GolfBall newBall; - success = c.GetCurrentBallLocation(img, b, newBall); - - if (!threeFootImgName.empty()) { - // This original HSV masking range from the calibration is not working well.Try a lower H ? - // b.ball_hsv_range_ = [(0, 0, 175), (175, 102, 255)] - GS_LOG_MSG(trace, "GET BALL LOCATION FOR 3 FEET"); - img = cv::imread(threeFootImgName); - - // TBD - Consider the fact that the ball should be no larger than the radius of the last found - // ball, and should have that ball"s average color - success = c.GetCurrentBallLocation(img, b, newBall); - // TBD _ FIX b.average_color_, b.median_color_, b.std_color_ = cu.GetBallColorRgb(c.img, newBall.ball_circle_) - } - - if (!fourFootImgName.empty()) { - GS_LOG_MSG(trace, "GET BALL LOCATION FOR 4 FEET"); - img = cv::imread(fourFootImgName); - success = c.GetCurrentBallLocation(img, b, newBall); - } -} - - - -bool testProjection() { - - cv::Mat ball1Img, ball2Img; - GolfBall ball1, ball2; - - - const std::string kBaseTestDir = "D:\\GolfSim\\C++Code\\GolfSim\\ImageProcessing\\"; - - const std::string k0_DegreeBallFileName_00 = kBaseTestDir + "test_ball_masked_0_deg_dulled.png"; - const std::string k45_DegreeBallFileName_00 = kBaseTestDir + "test_ball_masked_45_deg_dulled.png"; - - ball1Img = cv::imread(k0_DegreeBallFileName_00, cv::IMREAD_COLOR); - ball2Img = cv::imread(k45_DegreeBallFileName_00, cv::IMREAD_COLOR); - - /* - gfilters = create_gaborfilter() - image_g = apply_filter(myimage, gfilters) - - min_interval = 120 - max_interval = 250 - image_edge_g = cv2.Canny(image_g, min_interval, max_interval) - - // Create a simple matrix of 3D points - // cv::Mat<> test3DPoints{ {0,0,1} } - - std::vector test2DPoints{ {0,0,1}, {1,0,0} }; - */ - return true; -} - -void showVisualization() { - /* TBD - probably too much work to get Viz working - ///Createawindow - viz::Viz3dmyWindow("VizDemo"); - ///Starteventloop - myWindow.spin(); - - ///Eventloopisoverwhenpressedq,Q,e,E cout<<"Firsteventloopisover"<({ cv::Point(ball1Img.cols / 2, ball1Img.rows / 2) })); - LoggingTools::DebugShowImage("Undistorted " + img2FileName, unDistortedBall2Img, std::vector({ cv::Point(ball1Img.cols / 2, ball1Img.rows / 2) })); - } - else { - unDistortedBall1Img = ball1Img; - unDistortedBall2Img = ball2Img; - } - - ball1ImgColor = unDistortedBall1Img; - ball2ImgColor = unDistortedBall2Img; - - cv::Mat ball1ImgGray; - cv::cvtColor(unDistortedBall1Img, ball1ImgGray, cv::COLOR_BGR2GRAY); - cv::Mat ball2ImgGray; - cv::cvtColor(unDistortedBall2Img, ball2ImgGray, cv::COLOR_BGR2GRAY); - - ball1Img = ball1ImgGray; - ball2Img = ball2ImgGray; - - return true; -} - - -bool absResultsPass(const cv::Vec2d& expected, const cv::Vec2d& result, const cv::Vec2d& absTolerances) { - bool pass = true; - - if (std::abs(expected[0] - result[0]) > absTolerances[0]) { - pass = false; - } - - if (std::abs(expected[1] - result[1]) > absTolerances[1]) { - pass = false; - } - - return pass; -} - - -bool absResultsPass(const cv::Vec3d& expected, const cv::Vec3d& result, const cv::Vec3d& absTolerances) { - bool pass = true; - - if (std::abs(expected[0] - result[0]) > absTolerances[0]) { - pass = false; - } - - if (std::abs(expected[1] - result[1]) > absTolerances[1]) { - pass = false; - } - - if (std::abs(expected[2] - result[2]) > absTolerances[2]) { - pass = false; - } - - return pass; -} - - - -struct LocationAndSpinTestScenario { - int testIndex = 0; - std::string img1; - std::string img2; - CameraHardware::CameraModel cameraModel; - std::vector camera_positions_from_origin; // In meters. Size=2, one vector for each image. If same, then only one camera position was used. - cv::Vec2i calibrationBallCenter; // The x,y coordinates of where the first ball's picture should concentrate as an ROI - cv::Vec3d expectedposition_deltas_ball_perspective_; - cv::Vec2d expectedXYBallAngleDegrees; - cv::Vec3d expectedXYz_rotation_degreesationDegrees; -}; - - -const cv::Vec3d kRotationAngleToleranceAbs = { 10, 10, 5 }; -const cv::Vec3d kDeltaLocationBallToleranceAbs = { 1, 1, 1 }; -const cv::Vec2d kLaunchAngleToleranceAbs = { 10, 10 }; - - - -void convertInchesToMeters(const cv::Vec3d& expectedPositionsInches, cv::Vec3d& expectedPositionsMeters) { - expectedPositionsMeters[0] = CvUtils::InchesToMeters(expectedPositionsInches[0]); - expectedPositionsMeters[1] = CvUtils::InchesToMeters(expectedPositionsInches[1]); - expectedPositionsMeters[2] = CvUtils::InchesToMeters(expectedPositionsInches[2]); -} - - - -bool testBallPosition() { - - LocationAndSpinTestScenario tests[] { - - - { 25, "test_3280w_BS_camoff04x_8.5y_20z_ball00z_02y_00degx_spin00z_v1.png", "test_3280w_camoff04x_8.5y_20z_ball10z_03y_15degx_spin30z_v1.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.1016, 0.2159, 0.508), cv::Vec3d(0.1016, 0.2159, 0.508)}), cv::Vec2i(1100, 1000), cv::Vec3d(3, 2, 10), cv::Vec2d(45, 7), cv::Vec3d(0, 0, 30) - }, - - { 20, "test_pos_4056w_cam04offx_14y_17z_Ball_0inz_00degx_00iny_00Zsp_00.png", "test_pos_4056w_cam04offx_14y_17z_Ball_10inz_10degx_00iny_30Zsp_00.png", - CameraHardware::CameraModel::PiHQCam6mmWideLens, std::vector({cv::Vec3d(0.1016, 0.3556, 0.4318), cv::Vec3d(.1016, 0.3556, 0.4318)}), cv::Vec2i(1100, 2000), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - - { 30, "IRtest02.1-filter.png", "IRtest02.2-filter.png", - CameraHardware::CameraModel::PiHQCam6mmWideLens, std::vector({cv::Vec3d(.0914, 0.0953, 0), cv::Vec3d(.0914, 0.0953, 0)}), cv::Vec2i(1400, 800), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - - { 21, "test_pos_4056w_cam04offx_14y_17z_Ball_0inz_00degx_00iny_00Zsp_00.png", "test_pos_4056w_cam04offx_3.5y_19x_Ball15inz_15degx_00iny_40Zsp_00.png", - CameraHardware::CameraModel::PiHQCam6mmWideLens, std::vector({cv::Vec3d(.0914, 0.0953, 0), cv::Vec3d(.0914, 0.0953, 0)}), cv::Vec2i(1100, 2000), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - - - /* These next two are too unfocused to work well and will likely fail */ - { 1, "test_pos_2592w_BASE6off_22Dist_00inz_00degx_00iny_00.png", "test_pos_2592w_6off_22Dist_15inz_20degx_3iny_00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(700, 1000), cv::Vec3d(0, 0, 0), cv::Vec2d(-20, 0), cv::Vec3d(0, 0, 0) - }, - { 2, "test_pos_BS_3280w_6off_22Dist_00inz_00degx_0iny_00_00s.png", "test_pos_3280w_6off_22Dist_15inz_10degx_3iny_00_90s.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(800, 1200), cv::Vec3d(0, 0, 0), cv::Vec2d(0, 0), cv::Vec3d(0, 0, 0) - }, - - { 22, "tt.png", "t.png", - CameraHardware::CameraModel::PiHQCam6mmWideLens, std::vector({cv::Vec3d(.0914, 0.0953, 0), cv::Vec3d(.0914, 0.0953, 0)}), cv::Vec2i(1100, 1000), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - { 0, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00_blur.png", "test_pos_3280w_03off_20Dist_06inz_15degx_01iny_30sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(1100, 1400), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - { 4, "test_ball_spin_strong_landmarks_00d_3280w_dark_00.png", "test_ball_spin_strong_landmarks_20d_3280w_dark_00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0, 0, 0), cv::Vec2d(0, 0), cv::Vec3d(0, 0, 32) - }, - - /* These are better-focused and should work */ - { 11, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_3280w_13off_18Dist_15inz_15degx_01iny_00sp_04.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(.33, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0925, -0.025, 0.381), cv::Vec2d(-15, 3), cv::Vec3d(0, 0, 0) - }, - { 15, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_10degx_01iny_30sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.030, -0.038, 0.152), cv::Vec2d(-10, 4), cv::Vec3d(0, 0, 30) - }, - { 5, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_10degx_01iny_00sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(1200, 1100), cv::Vec3d(0.0, 0.025, 0.152), cv::Vec2d(15, 9.462), cv::Vec3d(0, 0, 0) - }, - { 3, "test_pos_2592w_BASE6off_22Dist_00inz_00degx_00iny_00.png", "test_pos_2592w_6off_22Dist_10inz_30degx_.75iny_00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(800, 1000), cv::Vec3d(0, 0, 0), cv::Vec2d(0, 0), cv::Vec3d(0, 0, 0) - }, - /* Clean pictures against a bllck back drop. This should be easy */ - { 7, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_3280w_13off_18Dist_15inz_15degx_01iny_30z20ysp_04.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(.33, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0925, -0.025, 0.381), cv::Vec2d(-15, 3), cv::Vec3d(0, 0, 26) - }, - /* Some tests that use only one camera */ - { 13, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_15degx_01.25iny_15sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.038, -0.038, 0.152), cv::Vec2d(-15, 4), cv::Vec3d(5, 0, 15) - }, - /* Shadow hits the ball and creates a line - seems to screw up the spin analysis*/ - { 12, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_15degx_01.25iny_15sp_Y00_shdw.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.038, -0.038, 0.152), cv::Vec2d(-15, 4), cv::Vec3d(0, 0, 15) - }, - { 14, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_15degx_01iny_30sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.038, -0.038, 0.152), cv::Vec2d(-15, 4), cv::Vec3d(5, 0, 30) - }, - { 16, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_00degx_01iny_30sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.0, -0.038, 0.152), cv::Vec2d(0, 4), cv::Vec3d(0, 0, 30) - }, - - - - /* Same ball picture - should return zeros for everything */ - { 8, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(0, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0, 0.0, 0.0), cv::Vec2d(0, 0), cv::Vec3d(0, 0, 0) - }, - /* Clean pictures against a black back drop. This should be easy */ - { 9, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_3280w_13off_18Dist_15inz_15degx_01iny_30z00ysp_04.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(.33, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0925, -0.025, 0.381), cv::Vec2d(-15, 3), cv::Vec3d(0, 0, 30) - }, - { 10, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_3280w_13off_18Dist_15inz_15degx_01iny_30z00ysp_04_F.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(.33, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0925, -0.025, 0.381), cv::Vec2d(-15, 3), cv::Vec3d(0, 0, 30) - }, - { 6, "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png", "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png", - CameraHardware::CameraModel::PiCam2, std::vector({ cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0) }), cv::Vec2i(1200, 1000), cv::Vec3d(0.0, 0.0, 0.0), cv::Vec2d(0.0, 0.0), cv::Vec3d(0, 0, 45) - }, - }; - - /* TBD - this lower resolution is not working for spin detection right now - FIX! - // ALREADY ABOVE const std::string k0_DegreeBallFileName_00 = "test_pos_2592w_BASE6off_22Dist_00inz_00degx_00iny_00.png"; - // const std::string kUnknown_DegreeBallFileName_00 = "test_pos_2592w_6off_22Dist_15inz_20degx_3iny_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_2592w_6off_22Dist_10inz_30degx_.75iny_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_6off_22Dist_00inz_00degx_0iny_00_00s.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_3280w_6off_22Dist_15inz_10degx_3iny_00_90s.png"; - - // Last trying this... - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_6off_20Dist_00inz_00degx_1iny_00sp_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_BS_3280w_6off_20Dist_10inz_20degx_3iny_30sp_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_03.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_BS_3280w_13off_18Dist_15inz_00degx_03iny_30sp_03.png"; - // The (simulated) camera and ball position will depend on the test images we use, above. - cv::Vec3d camera_positions_from_origin{ 0.33 ,0.0, 0.0 }; // In meters - cv::Vec2i calibrationBallCenter{ 1500, 1200 }; - - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_BS_3280w_13off_18Dist_15inz_15degx_01iny_30z20ysp_04.png"; - - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_0off_20Dist_00inz_00degx_00iny_00sp_Y00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_BS_3280w_13off_20Dist_00inz_05degx_01iny_45sp_Y00.png"; - cv::Vec3d camera_positions_from_origin{ 0.33 ,0.0, 0.0 }; // In meters - cv::Vec2i calibrationBallCenter{ 1500, 1200 }; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png"; - cv::Vec3d camera_positions_from_origin{ 0.0 ,0.0, 0.0 }; // In meters - cv::Vec2i calibrationBallCenter{ 1200, 1000 }; - - // ABOVE const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00_blur.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_3280w_03off_20Dist_06inz_15degx_01iny_30sp_Y00.png"; - // The (simulated) camera and ball position will depend on the test images we use, above. - cv::Vec3d camera_positions_from_origin{ 0.0 ,0.0, 0.0 }; // In meters - cv::Vec2i calibrationBallCenter{ 1300, 1200 }; - - */ - - boost::timer::cpu_timer timer1; - - bool allTestsPassed = true; - int numTotalTests = 0; - int numTestsFailed = 0; - - for (auto& t : tests) { - - numTotalTests++; - - const std::string k0_DegreeBallFileName_00 = t.img1; - const std::string kUnknown_DegreeBallFileName_00 = t.img2; - // The (simulated) camera and ball position will depend on the test images we use, above. - std::vector camera_positions_from_origin = t.camera_positions_from_origin; // In meters - cv::Vec2i calibrationBallCenter = t.calibrationBallCenter; - - - cv::Mat ball1ImgGray; - cv::Mat ball2ImgGray; - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - if (!read_test_images(k0_DegreeBallFileName_00, kUnknown_DegreeBallFileName_00, ball1ImgGray, ball2ImgGray, ball1ImgColor, ball2ImgColor, t.cameraModel)) { - GS_LOG_MSG(trace, "Failed to read valid images for Test No. " + std::to_string(t.testIndex)); - numTestsFailed++; - continue; - } - - - GolfSimCamera c; - c.camera_.resolutionX = ball1ImgColor.cols; - c.camera_.resolutionY = ball1ImgColor.rows; - c.camera_.resolutionXOverride = ball1ImgColor.cols; - c.camera_.resolutionYOverride = ball1ImgColor.rows; - - // Just for development on a non-Raspberry-Pi machine - c.camera_.firstCannedImageFileName = kBaseTestDir + k0_DegreeBallFileName_00; - c.camera_.secondCannedImageFileName = kBaseTestDir + kUnknown_DegreeBallFileName_00; - c.camera_.firstCannedImage = ball1ImgColor; - c.camera_.secondCannedImage = ball2ImgColor; - c.camera_.init_camera_parameters(t.cameraModel); - - long timeDelayuS = 7000; - GolfBall result_ball; - - GS_LOG_MSG(trace, "Starting Test No. " + std::to_string(t.testIndex) + "."); - - if (!c.analyzeShotImages(ball1ImgColor, ball2ImgColor, - timeDelayuS, - camera_positions_from_origin, - result_ball, - calibrationBallCenter)) { - GS_LOG_MSG(trace, "Failed Test No. " + std::to_string(t.testIndex)); - continue; - } - - double ball_velocity_meters_per_second_ = result_ball.velocity_; - - result_ball.PrintBallFlightResults(); - - bool testPassed = true; - - if (!absResultsPass(t.expectedXYz_rotation_degreesationDegrees, result_ball.ball_rotation_angles_camera_ortho_perspective_, kRotationAngleToleranceAbs)) { - GS_LOG_MSG(trace, "Test No. " + std::to_string(t.testIndex) + " - Failed ball rotation measurement."); - GS_LOG_MSG(trace, " Expected X,Y,Z rotation angles (in degrees) are: " + std::to_string((t.expectedXYz_rotation_degreesationDegrees[0])) + ", " + - std::to_string((t.expectedXYz_rotation_degreesationDegrees[1])) + ", " + std::to_string((t.expectedXYz_rotation_degreesationDegrees[2]))); - testPassed = false; - } - - cv::Vec3d expectedPositionsMeters; - convertInchesToMeters(t.expectedposition_deltas_ball_perspective_, expectedPositionsMeters); - - if (!absResultsPass(expectedPositionsMeters, result_ball.position_deltas_ball_perspective_, kDeltaLocationBallToleranceAbs)) { - GS_LOG_MSG(trace, "Test No. " + std::to_string(t.testIndex) + " - Failed ball delta location measurement."); - GS_LOG_MSG(trace, " Expected X,Y,Z deltas (ball perspective in inches) are: " + std::to_string(CvUtils::MetersToInches(t.expectedposition_deltas_ball_perspective_[0])) + ", " + - std::to_string(CvUtils::MetersToInches(t.expectedposition_deltas_ball_perspective_[1])) + ", " + std::to_string(CvUtils::MetersToInches(t.expectedposition_deltas_ball_perspective_[2]))); - testPassed = false; - } - - if (!absResultsPass(t.expectedXYBallAngleDegrees, result_ball.angles_ball_perspective_, kLaunchAngleToleranceAbs)) { - GS_LOG_MSG(trace, "Test No. " + std::to_string(t.testIndex) + " - Failed ball launch angle measurement."); - GS_LOG_MSG(trace, " Expected X,Y launch angles (ball perspective) (in degrees) are: " + std::to_string(t.expectedXYBallAngleDegrees[0]) + ", " + - std::to_string(t.expectedXYBallAngleDegrees[1])); - testPassed = false; - } - - if (!testPassed) { - numTestsFailed++; - } - - } - - GS_LOG_MSG(trace, "Final Test Statistics:\nTotal Tests: " + std::to_string(numTotalTests) + ".\nTests Failed: " + std::to_string(numTestsFailed) + "."); - - timer1.stop(); - boost::timer::cpu_times times = timer1.elapsed(); - std::cout << "analyzeShotImages timing: "; - std::cout << std::fixed << std::setprecision(8) - << times.wall / 1.0e9 << "s wall, " - << times.user / 1.0e9 << "s user + " - << times.system / 1.0e9 << "s system.\n"; - - - return true; -} - - -bool testSpinDetection() { - - /* - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_3280w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_14d_3280w_bright_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_01.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_08d_2592w_bright_01.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png"; - - const std::string k0_DegreeBallFileName_00 = k"test_ball_spin_strong_landmarks_00d_2592w_bright_01.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_35d_2592w_bright_01.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d-x_3280w_bright_02.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_15d-x_3280w_bright_02.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_st-landmarks_rand_1st_3280w_bright_04.png"; - const std::string kUnknown_DegreeBallFileName_00 = k"test_ball_spin_st-landmarks_rand_2nd_3280w_bright_04.png"; - - const std::string k0_DegreeBallFileName_00 = "test_pos_2592w_BASE6off_22Dist_00inz_00degx_00iny_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_2592w_6off_22Dist_10inz_30degx_.75iny_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png"; - */ - - const std::string k0_DegreeBallFileName_00 = "strobed_spin_test_0z_ctr_02.png"; - const std::string kUnknown_DegreeBallFileName_00 = "strobed_spin_test_30z_-30x_ctr_02.png"; - // const std::string kUnknown_DegreeBallFileName_00 = "loc_test_20_degree_right_strobed.png"; - // NEEDS TO BE CORRECTED FOR EACH TEST - Assume only 1 camera for now, so all deltas are 0 - // In meters - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - - - cv::Mat ball1ImgGray; - cv::Mat ball2ImgGray; - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - - CameraHardware::CameraModel cameraModel = CameraHardware::PiGSCam6mmWideLens; - - if (!read_test_images(k0_DegreeBallFileName_00, kUnknown_DegreeBallFileName_00, ball1ImgGray, ball2ImgGray, ball1ImgColor, ball2ImgColor, cameraModel, false /*No Undistort*/)) { - GS_LOG_MSG(trace, "Failed to read valid images."); - return false; - } - - // Get the ball data. We will calibrate based on the first ball and then get the second one - // using that calibrated data from the first ball. - - GolfSimCamera c; - c.camera_.init_camera_parameters(cameraModel); - - GolfBall ball1, ball2; - - c.camera_.firstCannedImageFileName = kBaseTestDir + k0_DegreeBallFileName_00; - c.camera_.firstCannedImage = ball1ImgColor; - - cv::Vec2i expectedBallCenter = cv::Vec2i(1456 / 3, 1088 / 2); - - if (GolfSimOptions::GetCommandLineOptions().search_center_x_ > 0) { - expectedBallCenter[0] = GolfSimOptions::GetCommandLineOptions().search_center_x_; - } - - if (GolfSimOptions::GetCommandLineOptions().search_center_y_ > 0) { - expectedBallCenter[1] = GolfSimOptions::GetCommandLineOptions().search_center_y_; - } - - - bool success = c.GetCalibratedBall(ball1ImgColor, ball1, expectedBallCenter); - - if (!success) { - GS_LOG_MSG(trace, "Failed to GetCalibratedBall."); - return false; - } - c.camera_.firstCannedImageFileName = kBaseTestDir + k0_DegreeBallFileName_00; - c.camera_.secondCannedImageFileName = kBaseTestDir + kUnknown_DegreeBallFileName_00; - c.camera_.firstCannedImage = ball1ImgColor; - c.camera_.secondCannedImage = ball2ImgColor; - - success = c.GetCurrentBallLocation(ball2ImgColor, ball1, ball2); - - if (!success) { - GS_LOG_MSG(trace, "Could not find 2nd ball"); - return false; - } - - boost::timer::cpu_timer timer1; - - cv::Vec3d rotationResults = BallImageProc::GetBallRotation(ball1ImgGray, ball1, ball2ImgGray, ball2); - - timer1.stop(); - boost::timer::cpu_times times = timer1.elapsed(); - std::cout << "BallImageProc::GetBallRotation: "; - std::cout << std::fixed << std::setprecision(8) - << times.wall / 1.0e9 << "s wall, " - << times.user / 1.0e9 << "s user + " - << times.system / 1.0e9 << "s system.\n"; - - // TBD - Now implemented in the GetBallRotation() function. See how the original image would look if rotated as the GetBallRotation function calculated - - std::string s = "Ball Rotation (degrees): X: " + std::to_string(rotationResults[0]) + "\tY: " + std::to_string(rotationResults[1]) + "\tZ: " + std::to_string(rotationResults[2]) + "\n"; - - return true; -} - - - -bool TestSpin() { - - GolfBall ball1; - GolfBall ball2; - cv::Mat img1; - cv::Mat img2; - - GS_LOG_MSG(info, "TestSpin is in process."); - GS_LOG_MSG(trace, "Please position the ball for a first image and hit any key."); - - int keyPressed = cv::waitKey(0) & 0xFF; - -#ifdef __unix__ - - // Find the first ball. This will cause a pause to view the image - // that will allow the user to reposition the ball for a second time - if (!CheckForBall(ball1, img1)) { - GS_LOG_MSG(trace, "Failed to CheckForBall."); - return false; - } - - GS_LOG_MSG(trace, "Position the ball for a second image and hit any key."); - - if (!CheckForBall(ball2, img2)) { - GS_LOG_MSG(trace, "Failed to CheckForBall."); - return false; - } - - LoggingTools::LogImage("test_spin_img_ball1", img1, std::vector < cv::Point >{}, true); - LoggingTools::LogImage("test_spin_img_ball2", img2, std::vector < cv::Point >{}, true); - - cv::Mat grayImage1; - cv::Mat grayImage2; - cv::cvtColor(img1, grayImage1, cv::COLOR_BGR2GRAY); - cv::cvtColor(img2, grayImage2, cv::COLOR_BGR2GRAY); - - cv::Vec3d rotationResults = BallImageProc::GetBallRotation(grayImage1, ball1, grayImage2, ball2); -#endif - - return true; -} - -bool testAnalyzeStrobedBalls() { - - // Have to call this here because we are not starting the FSM, but need (simulated) pulse informatiom - PulseStrobe::InitGPIOSystem(); - - std::string kTwoImageTestTeedBallImage; - std::string kTwoImageTestStrobedImage; - std::string kTwoImageTestPreImage; - - GolfSimConfiguration::SetConstant("gs_config.testing.kTwoImageTestTeedBallImage", kTwoImageTestTeedBallImage); - GolfSimConfiguration::SetConstant("gs_config.testing.kTwoImageTestStrobedImage", kTwoImageTestStrobedImage); - GolfSimConfiguration::SetConstant("gs_config.testing.kTwoImageTestPreImage", kTwoImageTestPreImage); - - const std::string kTestCamImageFileName_00 = kTwoImageTestTeedBallImage; - - const std::string kTestCam2StrobedmageFileName = kTwoImageTestStrobedImage; - - cv::Mat ball1ImgGray; - cv::Mat ball2ImgGray; - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - - CameraHardware::CameraModel cameraModel = CameraHardware::PiGSCam6mmWideLens; - - if (!read_test_images(kTestCamImageFileName_00, kTestCam2StrobedmageFileName, ball1ImgGray, ball2ImgGray, ball1ImgColor, ball2ImgColor, cameraModel,false /*No Undistort*/)) { - GS_LOG_MSG(trace, "Failed to read valid images."); - return false; - } - - // Also read the test pre-image - cv::Mat camera2_pre_image_color; - std::string preImageFileName = kBaseTestDir + kTwoImageTestPreImage; - - GS_LOG_MSG(trace, "Pre Image1: " + preImageFileName); - - camera2_pre_image_color = cv::imread(preImageFileName, cv::IMREAD_COLOR); - - if (camera2_pre_image_color.empty()) { - return false; - } - - GolfBall result_ball; - cv::Vec3d rotation_results; - - if (!GolfSimCamera::ProcessReceivedCam2Image(ball1ImgColor, ball2ImgColor, camera2_pre_image_color, result_ball, rotation_results)) { - GS_LOG_MSG(error, "Failed ProcessReceivedCam2Image."); - return false; - } - - GsGSProResults results(result_ball); - GS_LOG_MSG(trace, "Results are: " + results.Format()); - - PulseStrobe::DeinitGPIOSystem(); - - return true; -} - - - - -bool test_strobed_balls_detection() { - - const std::string kCam1BallOnTee = "test_strobe_spin_0_0_0.png"; - const std::string kCam2BallInFlight = "test_strobe_spin_0_0_45.png"; - - - cv::Mat ball1ImgGray; - cv::Mat ball2ImgGray; - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - if (!read_test_images(kCam1BallOnTee, kCam2BallInFlight, ball1ImgGray, ball2ImgGray, ball1ImgColor, ball2ImgColor, CameraHardware::PiGSCam6mmWideLens)) { - GS_LOG_MSG(error, "Failed to read valid images."); - return false; - } - - BallImageProc *ip = get_image_processor(); - - ip->image_name_ = kBaseTestDir + kCam1BallOnTee; - - GolfSimCamera c; - //get camera operational and make sure working correctly - c.camera_.cameraModel = CameraHardware::CameraModel::PiGSCam6mmWideLens; - - GolfBall ball1, ball2; - - c.camera_.firstCannedImageFileName = kBaseTestDir + kCam1BallOnTee; - c.camera_.firstCannedImage = ball1ImgColor; - c.camera_.secondCannedImageFileName = kBaseTestDir + kCam2BallInFlight; - c.camera_.secondCannedImage = ball2ImgColor; - c.camera_.init_camera_parameters(CameraHardware::CameraModel::PiGSCam6mmWideLens); - - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - - cv::Vec2i expectedBallCenter = cv::Vec2i(1456 / 2, 1088 / 2); - - if (GolfSimOptions::GetCommandLineOptions().search_center_x_ > 0) { - expectedBallCenter[0] = GolfSimOptions::GetCommandLineOptions().search_center_x_; - } - - if (GolfSimOptions::GetCommandLineOptions().search_center_y_ > 0) { - expectedBallCenter[1] = GolfSimOptions::GetCommandLineOptions().search_center_y_; - } - - bool success = c.GetCalibratedBall(ball1ImgColor, ball1, expectedBallCenter ); - - if (!success) { - GS_LOG_MSG(error, "Failed to determine first ball."); - return false; - } - - success = c.GetCurrentBallLocation(ball2ImgColor, ball1, ball2); - - if (!success) { - GS_LOG_MSG(error, "Failed to determine second ball."); - return false; - } - - - boost::timer::cpu_timer timer1; - - cv::Vec3d rotationResults = BallImageProc::GetBallRotation(ball1ImgGray, ball1, ball2ImgGray, ball2); - - timer1.stop(); - boost::timer::cpu_times times = timer1.elapsed(); - std::cout << "BallImageProc::GetBallRotation: "; - std::cout << std::fixed << std::setprecision(8) - << times.wall / 1.0e9 << "s wall, " - << times.user / 1.0e9 << "s user + " - << times.system / 1.0e9 << "s system.\n"; - - // TBD - Now implemented in the GetBallRotation() function. See how the original image would look if rotated as the GetBallRotation function calculated - - std::string s = "Ball Rotation (degrees): X: " + std::to_string(rotationResults[0]) + "\tY: " + std::to_string(rotationResults[1]) + "\tZ: " + std::to_string(rotationResults[2]) + "\n"; - - - return true; -} - - -bool test_hit_trigger() { - - GolfBall ball; - - //get camera operational and make sure working correctly - GolfSimCamera c; - c.camera_.cameraModel = CameraHardware::CameraModel::PiCam2; - - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - - const std::string kStationaryBallFileName_00 = kBaseTestDir + "move_test_ball_present_2592w_00.png"; - const std::string kStationaryBallFileName_01 = kBaseTestDir + "move_test_ball_present_2592w_01.png"; - const std::string kPreHitCloseBallFileName_00 = kBaseTestDir + "move_test_ball_and_club_present_2592w_00.png"; - const std::string kPostHitBallGoneFileName_00 = kBaseTestDir + "move_test_no_ball_present_2592w_00.png"; - - ball1ImgColor = cv::imread(kStationaryBallFileName_00, cv::IMREAD_COLOR); - ball2ImgColor = cv::imread(kStationaryBallFileName_01, cv::IMREAD_COLOR); - - cv::Mat ball1Img; - cv::Mat ball2Img; - ball1Img = undistort_image(ball1ImgColor, c.camera_.cameraModel); - ball2Img = undistort_image(ball2ImgColor, c.camera_.cameraModel); - - c.camera_.resolutionX = ball1Img.cols; - c.camera_.resolutionY = ball1Img.rows; - c.camera_.resolutionXOverride = ball1Img.cols; - c.camera_.resolutionYOverride = ball1Img.rows; - c.camera_.firstCannedImageFileName = kBaseTestDir + kStationaryBallFileName_00; - c.camera_.secondCannedImageFileName = kBaseTestDir + kStationaryBallFileName_01; - c.camera_.firstCannedImage = ball1Img; - c.camera_.secondCannedImage = ball2Img; - c.camera_.init_camera_parameters(c.camera_.cameraModel); - - - if (!c.prepareToTakePhoto()) { - GS_LOG_MSG(error, "Cannot prepare camera for photos"); - return false; - } - - - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - cv::Vec2i expectedBallCenter = cv::Vec2i(1300, 1000); - - GS_LOG_MSG(trace, "Looking for ball on tee"); - bool success = false; - bool timedOut = false; - cv::Mat img; - - while (!success && !timedOut) - { - img = c.camera_.take_photo(); - - if (img.empty()) { - GS_LOG_MSG(error, "Could not take picture!"); - return false; - } - - success = c.GetCalibratedBall(img, ball, expectedBallCenter); - GS_LOG_MSG(trace, "."); - } - - if (!success) { - LoggingTools::Warning("Could not find the first ball to calibrate!"); - return false; - } - - GS_LOG_MSG(trace, "Found ball" + ball.Format()); - - cv::Mat result_image; - - // ball.ball_circle_ = GsCircle{ (float)ball.x(), (float)ball.y(), (float)ball.measured_radius_pixels_ }; - - // TBD - override the camera hardware based on the image we find - if (BallImageProc::WaitForBallMovement(c, result_image, ball, 200)) { - GS_LOG_MSG(trace, "wait_for_movement returned True"); - LoggingTools::DebugShowImage("First image with movement", result_image); - } - else { - GS_LOG_MSG(trace, "wait_for_movement returned False"); - } - - return true; -} - - -bool TestGSProMessage() { - - try - { - std::string kGSProConnectAddress; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", kGSProConnectAddress); - std::string kGSProConnectPort; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", kGSProConnectPort); - - boost::asio::io_context io_context; - - tcp::resolver resolver(io_context); - GS_LOG_MSG(trace, "Connecting to GSProServer at address: " + kGSProConnectAddress + ":" + kGSProConnectPort); - tcp::resolver::results_type endpoints = resolver.resolve(kGSProConnectAddress, kGSProConnectPort); - - tcp::socket socket(io_context); - - boost::asio::connect(socket, endpoints); - - std::array buf; - boost::system::error_code error; - - GolfBall ball; - ball.velocity_ = 123.6; - GsGSProResults test_result(ball); - test_result.speed_mph_ = 123; - test_result.vla_deg_ = 23.4; - test_result.hla_deg_ = 1.23; - test_result.back_spin_rpm_ = 3456.0; - test_result.side_spin_rpm_ = 567.8; - - std::string test_msg = test_result.Format(); - - size_t write_length = socket.write_some(boost::asio::buffer(test_msg), error); - - std::cout << "Wrote " << write_length << " bytes to server socket." << std::endl; - - - std::string buffer_string; - - GS_LOG_MSG(trace, "Waiting to receive response from GSProserver."); - - size_t len = socket.read_some(boost::asio::buffer(buf), error); - if (len == 0) { - GS_LOG_MSG(warning, "Received 0-length message from server."); - return false; - } - - // Null-terminate the string - buf[len] = (char)0; - buffer_string += std::string(buf.data()); - GS_LOG_MSG(trace, " Read some data (" + std::to_string(len) + " bytes) : " + buffer_string); - - if (error == boost::asio::error::eof) - return false; // Connection closed cleanly by peer. - else if (error) - throw boost::system::system_error(error); // Some other error. - - std::string json = buf.data(); - - GsGSProResponse gspro_response; - if (!gspro_response.ParseJson(json)) { - GS_LOG_MSG(error, "Failed TestGSProMessage - Could not parse json: " + json); - return false; - } - - GS_LOG_MSG(trace, "Received GSPro Result of: " + gspro_response.Format() + ". Sleeping for a bit for test."); -#ifdef __unix__ - sleep(33); -#endif - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestGSProMessage - Error was: " + std::string(e.what())); - return false; - } - - return true; -} - -bool TestGSProServer() { - try - { - int kGSProConnectPort; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", kGSProConnectPort); - - boost::asio::io_context io_context; - GsGSProTestServer server(io_context, kGSProConnectPort); - GS_LOG_MSG(trace, "About to call io_context.run()"); - io_context.run(); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestGSProServer - Error was: " + std::string(e.what())); - return false; - } - - return true; -} - -void test_gspro_communication() { - - GolfBall ball; - ball.rotation_speeds_RPM_[2] = 5000.; - ball.rotation_speeds_RPM_[0] = 100.; - GsGSProResults results(ball); - std::string json = results.Format(); - GS_LOG_MSG(debug, json); -} - -void test_function(int argc, char* argv[]) -{ - GS_LOG_MSG(trace, "Test called"); - - // Start of testing - GS_LOG_MSG(trace, "Running on " + GolfSimPlatform); // platform.platform()); - GS_LOG_MSG(trace, "OpenCV Version " + std::string(CV_VERSION)); - - // test_strobed_balls_detection(); - // testBallPosition(); - // test_gspro_communication(); - -#ifdef __unix__ - - if (GolfSimOptions::GetCommandLineOptions().shutdown_) { - - GS_LOG_MSG(trace, "Running in global shutdown mode."); - if (!PerformSystemStartupTasks()) { - GS_LOG_MSG(error, "Failed to PerformSystemStartupTasks."); - return; - } - - // Give the IPC threads time to start - sleep(2); - - GolfSimIPCMessage ipc_message(GolfSimIPCMessage::IPCMessageType::kShutdown); - GolfSimIpcSystem::SendIpcMessage(ipc_message); - - // Give the IPC thread time to send the message - sleep(1); - - PerformSystemShutdownTasks(); - - return; - } - - if (GolfSimOptions::GetCommandLineOptions().send_test_results_) { - - GS_LOG_MSG(trace, "Running in send_test_results mode."); - if (!PerformSystemStartupTasks()) { - GS_LOG_MSG(error, "Failed to PerformSystemStartupTasks."); - return; - } - - // Give the IPC threads time to start - sleep(2); - - GolfSimIPCMessage ipc_message(GolfSimIPCMessage::IPCMessageType::kResults); - - - - GsIPCResult& results = ipc_message.GetResultsForModification(); - - results.result_type_ = GsIPCResultType::kHit; - results.speed_mpers_ = 50 + rand() % 40; - results.carry_meters_ = 100 + rand() % 150; - results.launch_angle_deg_ = 12.34; - // Tests edge case where a float can be interpreted by MSGPACK as an integer if there is no fractional part - results.side_angle_deg_ = 3.0; - results.back_spin_rpm_ = 4567; - results.side_spin_rpm_ = 89; - results.message_ = "Test message"; - - results.log_messages_.push_back("[2024-01-22 11:19:36.321980] (0x000053c0) [debug] [bool __cdecl golf_sim::GolfSimCamera::GetCalibratedBall(const class cv::Mat &,const class cv::Vec &,class golf_sim::GolfBall &,const class cv::Vec &)(D:\\GolfSim\\LM\\ImageProcessing\\gs_camera.cpp:118)<-bool __cdecl golf_sim::GolfSimCamera::GetCalibratedBall(const class cv::Mat &,const class cv::Vec &,class golf_sim::GolfBall &,const class cv::Vec &)(D:\\GolfSim\\LM\\ImageProcessing\\gs_camera.cpp:86)...] Looking for a ball with min/max radius (pixels) of: 56, 85"); - results.log_messages_.push_back("[2024 - 01 - 22 11:19 : 36.497181](0x000053c0)[trace][void __cdecl golf_sim::LoggingTools::ShowImage(class std::basic_string, class std::allocator >, const class cv::Mat&, const class std::vector, class std::allocator > > &)(D:\\GolfSim\\LM\\ImageProcessing\\logging_tools.cpp:270) < -bool __cdecl golf_sim::GolfSimCamera::GetCalibratedBall(const class cv::Mat&, const class cv::Vec &, class golf_sim::GolfBall&, const class cv::Vec &)(D:\\GolfSim\\LM\\ImageProcessing\\gs_camera.cpp:118)...] ShowImage(AreaMaskImage Photo, (sizeX, sizeY) = (1456, 1088)"); - - GS_LOG_MSG(trace, "Sending a test IPC Results Message:\n" + results.Format()); - GolfSimIpcSystem::SendIpcMessage(ipc_message); - - // Give the IPC thread time to send the message - sleep(1); - - PerformSystemShutdownTasks(); - - return; - } - - - // In this mode, we just try to send the shutter and strobe pulses asap - // Only do so if this is the camera1 system, of course - // if in cam2_still_mode on the camera2 system, the only difference in - // operation will be the number of strobe pulses is cut to 1 - if (GolfSimOptions::GetCommandLineOptions().camera_still_mode_) { - - if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1) { - // Just call the get-the-ball function to trigger an image capture. - // It will be saved as an artifact - - // TBD - - } - - - if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2) { - - // TBD - Not completed yet - GS_LOG_MSG(trace, "Running in pulse cam2 still mode on camera1 system. Will take one strobed picture in camera2 system."); - - if (GolfSimOptions::GetCommandLineOptions().output_filename_.empty()) { - GS_LOG_MSG(trace, "No output output_filename specified. Will save picture as: " + std::string(LoggingTools::kDefaultSaveFileName)); - } - - // Will need the GPIO system for the camera trigger and stobe - // The camera2 system will also have to be setup. - if (!PulseStrobe::InitGPIOSystem()) { - GS_LOG_MSG(error, "Failed to InitGPIOSystem."); - return; - } - - if (!PulseStrobe::SendCameraPrimingPulses()) { - GS_LOG_MSG(error, "FAILED to PulseStrobe::SendCameraPrimingPulses"); - } - - // Give the camera2 system a moment - sleep(1); - - PulseStrobe::SendExternalTrigger(); - - // At this point, the camera2 sytem should take a picture and return it - - } - - return; - } - - if (GolfSimOptions::GetCommandLineOptions().perform_pulse_test_) { - - GS_LOG_MSG(trace, "Running in pulse test mode."); - if (!PulseStrobe::InitGPIOSystem()) { - GS_LOG_MSG(error, "Failed to InitGPIOSystem."); - return; - } - - while (true) { - PulseStrobe::SendExternalTrigger(); - sleep(3); - } - return; - } - - switch (GolfSimOptions::GetCommandLineOptions().system_mode_) { - - case SystemMode::kCamera1: - case SystemMode::kCamera1TestStandalone: - { - GS_LOG_MSG(info, "Running in kCamera1 or kCamera1TestStandalone mode."); - state::InitializingCamera1System camera1_state; - RunGolfSimFsm(camera1_state); - break; - } - - case SystemMode::kCamera2: - case SystemMode::kCamera2TestStandalone: - { - GS_LOG_MSG(info, "Running in kCamera2 or kCamera2TestStandalone mode."); - - state::InitializingCamera2System camera2_state; - RunGolfSimFsm(camera2_state); - break; - } - - case SystemMode::kTestSpin: - { - GS_LOG_MSG(info, "Running in kTestSpin mode."); - - TestSpin(); - break; - } - - case SystemMode::kTest: - { - testAnalyzeStrobedBalls(); - // test_hit_trigger(); - break; - } - - case SystemMode::kCamera1Calibrate: - case SystemMode::kCamera2Calibrate: - { - GS_LOG_MSG(info, "Running in kCamera1Calibrate or kCamera2Calibrate mode."); - - // We will want to send a calibration message to any monitor UIs - if (!GolfSimIpcSystem::InitializeIPCSystem()) { - GS_LOG_MSG(info, "Failed to InitializeIPCSystem."); - return; - } - - GolfBall ball; - cv::Mat img; - // In addition to checking for the ball, this method will send an IPC results - // message if we are in calibration mode. - - GS_LOG_MSG(info, "Calibration Results (Distance of kCamera (1 OR 2) CalibrationDistanceToBall):"); - double average_focal_length = 0.0; - const int number_attempts = 10; - int number_samples = 0; - - for (int i = 0; i < number_attempts; i++) { - if (!CheckForBall(ball, img)) { - GS_LOG_MSG(trace, "Failed to CheckForBall - skipping"); - continue; - } - - number_samples++; - GS_LOG_MSG(trace, "Performing focal length calibration"); - - average_focal_length += ball.calibrated_focal_length_; - std::string calibration_results_message = "Focal Length = " + std::to_string(ball.calibrated_focal_length_) + "."; - GS_LOG_MSG(info, calibration_results_message); -#ifdef __unix__ - GsUISystem::SendIPCStatusMessage(GsIPCResultType::kCalibrationResults, calibration_results_message); -#endif - } - - average_focal_length /= number_samples; - GS_LOG_MSG(info, "====> Average Focal Length = " + std::to_string(average_focal_length) + ".Set this value into the gs_config.json file."); - - GolfSimIpcSystem::ShutdownIPCSystem(); - } - break; - - case SystemMode::kTestGSProMessage: - { - if (!TestGSProMessage()) { - GS_LOG_MSG(info, "Failed to TestGSProMessage."); - return; - } - } - break; - - case SystemMode::kTestGSProServer: - { - if (!TestGSProServer()) { - GS_LOG_MSG(info, "Failed to TestGSProSever."); - return; - } - } - break; - - case SystemMode::kCamera1BallLocation: - case SystemMode::kCamera2BallLocation: - { - GS_LOG_MSG(info, "Running in kCamera1BallLocation or kCamera2BallLocation mode."); - - // We will want to send a test rest results message to any monitor UIs - if (!GolfSimIpcSystem::InitializeIPCSystem()) { - GS_LOG_MSG(info, "Failed to InitializeIPCSystem."); - return; - } - - GolfBall ball; - cv::Mat img; - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - - CameraHardware::CameraModel cameraModel = CameraHardware::PiGSCam6mmWideLens; - GolfSimCamera c; - c.camera_.init_camera_parameters(cameraModel); - - - while (GolfSimGlobals::golf_sim_running_) { - // In addition to checking for the ball, this method will send an IPC results - // message if we are in calibration mode. - bool status = CheckForBall(ball, img); - - if (status) { - - GS_LOG_MSG(info, "Found Ball:"); -/* TBD - REMOVE - if (!c.GolfSimCamera::ComputeSingleBallXYZOrthoCamPerspective(ball, camera_positions_from_origin, GolfSimCamera::kCamera1Angles)) { - GS_LOG_MSG(error, "ComputeBallDeltas: Could not ComputeSingleBallXYZOrthoCamPerspective for ball1"); - return; - } -*/ - GS_LOG_MSG(info, ball.Format() + "\n\n"); - - GsCircle final_circle; - - cv::Mat gray_image; - cv::cvtColor(img, gray_image, cv::COLOR_BGR2GRAY); - - bool choose_largest_final_ball = false; - - if (!BallImageProc::DetermineBestCircle(gray_image, ball, choose_largest_final_ball, final_circle)) { - GS_LOG_MSG(error, "Failed to DetermineBestCircle."); - } - else - { - // A ball was found - show it to the user - - GS_LOG_MSG(trace, "Best Circle xiRadius was: " + std::to_string(final_circle[2]) + " pixels.\n\n"); - } - - - // TBD - Send Test results IPC message - } - } - - GolfSimIpcSystem::ShutdownIPCSystem(); - } - break; - - default: - break; - } - -#else - // TBD - REMOVE -Just for testing - std::string address; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", address); - - testAnalyzeStrobedBalls(); - // testSpinDetection(); -#endif - - return; - - /* - - //TBD - Temporary to get ball location for action trigger test - // First call will ensure we have the OpenCV library loaded by doing a dummy test - for (int i = 0; i < 10; i++) { - test_hit_trigger(); - } - - - test_calibrated_location("D:\\GolfSim\\C++Code\\GolfSim\\ImageProcessing\\move_test_ball_present_1024w_00.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-3-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-4-feet-HiRes_01.png"); - - - - test_calibrated_location("D:\\GolfSim\\C++Code\\GolfSim\\ImageProcessing\\move_test_ball_present_00.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-3-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-4-feet-HiRes_01.png"); - test_calibrated_location("./Images/FakePiCamGolfBall-Perfect-Clr-Green-2-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-3-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-4-feet-HiRes_01.png"); - - test_calibrated_location("./Images/FakePiCamGolfBall-Perfect-Clr-White-2-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-White-3-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-White-4-feet-HiRes_01.png"); - - test_certain_images(); - - test_all_test_files(); - */ -} - - - -int main(int argc, char *argv[]) -{ - if (!GolfSimOptions::GetCommandLineOptions().Parse(argc, argv)) - { - GS_LOG_MSG(error, "Could not GetCommandLineOptions. Exiting."); - return 0; - } - - LoggingTools::InitLogging(); - - GS_LOG_MSG(info, "Golf Sim Launch Monitor Started"); - - GolfSimOptions::GetCommandLineOptions().Print(); - - std::string config_file_name = "golf_sim_config.json"; - - if (!GolfSimOptions::GetCommandLineOptions().config_file_.empty()) { - config_file_name = GolfSimOptions::GetCommandLineOptions().config_file_; - } - - if (!GolfSimConfiguration::Initialize(config_file_name)) { - GS_LOG_MSG(error, "Could not intialize configuration module using config file: " + config_file_name + ". Exiting."); - return 0; - } - - LoggingTools::logging_tool_wait_for_keypress_ = GolfSimOptions::GetCommandLineOptions().wait_for_key_on_images_; - - // TBD - consider if there is a better place for this? - GolfSimGlobals::golf_sim_running_ = true; - - test_function(argc, argv); - - GS_LOG_MSG(trace, "Finished test_function."); - - // GS_LOG_MSG(trace, "Waiting for any keypress to end program."); - // cv::waitKey(0); - - GS_LOG_MSG(trace, "Tests Complete"); -} From a5d91207344dce3fc031155d2947d141c6dc66bc Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 30 Jul 2026 21:23:30 -0400 Subject: [PATCH 069/156] remove cli flags that the served config always overrides --- .../ImageProcessing.vcxproj.user | 4 +-- .../ImageProcessing/gs_calibration.cpp | 2 +- .../ImageProcessing/gs_options.cpp | 9 ----- .../LMSourceCode/ImageProcessing/gs_options.h | 36 +++++-------------- 4 files changed, 11 insertions(+), 40 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user index d9fdea2e..a1b0dcba 100755 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user @@ -1,11 +1,11 @@  - --show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550 + --base_image_logging_dir L:\ --lm_comparison_mode=0 --system_mode camera1 PITRAC_BASE_IMAGE_LOGGING_DIR=L:\ PATH=%PATH%;E:\Dev_Libs\opencv\build\x64\vc16\bin;E:\Dev_Libs\boost_1_90_0\lib64-msvc-14.3 HOME=. WindowsLocalDebugger - --show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=1 --logging_level info --artifact_save_level=all --wait_keys 0 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=1 --logging_level info --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level info --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 0 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550| + --base_image_logging_dir L:\ --lm_comparison_mode=1 --system_mode camera1|--base_image_logging_dir L:\ --lm_comparison_mode=0 --system_mode camera1| \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp index 99c30665..01995aaa 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp @@ -151,7 +151,7 @@ namespace golf_sim { bool result = ip->GetBall(color_image, ball, return_balls, nullROI, search_mode); if (!result || return_balls.empty()) { - GS_LOG_MSG(error, "GetBall() failed to get a ball. Consider setting --show_images=1 in order to determine why no ball was found."); + GS_LOG_MSG(error, "GetBall() failed to get a ball. Consider enabling Show Debug Images (gs_config.debug.kShowDebugImages) to determine why no ball was found."); return -1.0; } diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.cpp b/Software/LMSourceCode/ImageProcessing/gs_options.cpp index 18adcdd1..1e6f6f5e 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_options.cpp @@ -74,15 +74,6 @@ bool GolfSimOptions::Parse(int argc, char *argv[]) system_mode_ = (SystemMode)mode_table[system_mode_string_]; - if (!SetArtifactSaveLevel(artifact_save_level_string_)) - throw std::runtime_error("Invalid artifact_save_level: " + artifact_save_level_string_); - - if (!SetLoggingLevel(logging_level_string_)) - throw std::runtime_error("Invalid log_level: " + logging_level_string_); - - if (!SetGolferOrientation(golfer_orientation_string_)) - throw std::runtime_error("Invalid golfer_orientation: " + golfer_orientation_string_); - if (help_) { std::cout << options_; diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.h b/Software/LMSourceCode/ImageProcessing/gs_options.h index 9f556dbe..f0dd53a6 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.h +++ b/Software/LMSourceCode/ImageProcessing/gs_options.h @@ -70,14 +70,8 @@ namespace golf_sim { "Print this help message") ("version", value(&version_)->default_value(false)->implicit_value(true), "Displays the build version number") - ("golfer_orientation", value(&golfer_orientation_string_)->default_value("right_handed"), - "Set the golfer's handed-ness (right_handed, left_handed)") ("system_mode", value(&system_mode_string_)->default_value("test"), "Set the system's operating mode (camera1, camera1_test_standalone, camera1Calibrate, camera2Calibrate, test_spin, camera1_ball_location, camera2_ball_location, test_gspro_server, automated_testing, camera1AutoCalibrate, camera2AutoCalibrate, test)") - ("logging_level", value(&logging_level_string_)->default_value("warn"), - "Set the system's logging level (trace, debug, info, warn, error, none)") - ("artifact_save_level", value(&artifact_save_level_string_)->default_value("final_results_only"), - "Set the system's level of saving artifact images to files (none, final_results_only, all)") ("shutdown", value(&shutdown_)->default_value(false)->implicit_value(true), "Request clean shutdown") ("cam_still_mode", value(&camera_still_mode_)->default_value(false)->implicit_value(true), @@ -92,20 +86,10 @@ namespace golf_sim { "Write any still picture to the specified filename") ("pulse_test", value(&perform_pulse_test_)->default_value(false)->implicit_value(true), "Continually sends strobe and shutter signals") - ("practice_ball", value(&practice_ball_)->default_value(false)->implicit_value(true), - "Configure system to expect a lightweight, soft, practice ball") - ("wait_keys", value(&wait_for_key_on_images_)->default_value(false)->implicit_value(true), - "0 = Don't wait for a key press after showing each debug image, 1 = Do wait") ("run_single_pi", value(&run_single_pi_)->default_value(true)->implicit_value(true), "Deprecated — always single Pi. Accepted for backward compatibility.") - ("show_images", value(&show_images_)->default_value(false)->implicit_value(true), - "0 = Don't show any debug/trace images in windows on the screen, 1 = Do") ("use_non_IR_camera", value(&use_non_IR_camera_)->default_value(false)->implicit_value(true), "1 = The camera in use by this system is not an IR camera (and will likely need less gain)") - ("search_center_x", value(&search_center_x_)->default_value(0), - "Set the x coordinate of the center of the ball-search circle") - ("search_center_y", value(&search_center_y_)->default_value(0), - "Set the y coordinate of the center of the ball-search circle") ("simulate_found_ball", value(&simulate_found_ball_)->default_value(false)->implicit_value(true), "Causes camera1 system to act as though a ball was found even if none is present.") ("camera_gain", value(&camera_gain_)->default_value(0.0), @@ -116,10 +100,6 @@ namespace golf_sim { "Specify the full path (with an ending '/') where diagnostic images are to be written. Default is: ") ("web_server_share_dir", value(&web_server_share_dir_)->default_value(""), "Specify the full path (with an ending '/') where diagnostic images are to be written. Default is: ") - ("e6_host_address", value(&e6_host_address_)->default_value(""), - "Specify the name or IP address of the host PC that is running the E6 simulator. Default is: , indicating no TruGolf sim is connected.") - ("gspro_host_address", value(&gspro_host_address_)->default_value(""), - "Specify the name or IP address of the host PC that is running the GSPro simulator. Default is: , indicating no GSPro sim is connected.") ("web_server_port", value(&web_server_port_)->default_value(8080), "Port of the PiTrac web server (on localhost) to fetch configuration from and post results to.") ("cmd_file,cmd", value(&command_line_file_)->implicit_value("config.txt"), @@ -145,28 +125,28 @@ namespace golf_sim { bool lm_comparison_mode_; bool send_test_results_; bool skip_wait_armed_; - bool practice_ball_; + bool practice_ball_ = false; bool perform_pulse_test_; bool use_non_IR_camera_; bool simulate_found_ball_; std::string output_filename_; std::string system_mode_string_; - std::string artifact_save_level_string_; - std::string logging_level_string_; + std::string artifact_save_level_string_ = "final_results_only"; + std::string logging_level_string_ = "warn"; std::string command_line_file_; std::string msg_broker_address_; std::string base_image_logging_dir_; std::string web_server_share_dir_; std::string e6_host_address_; std::string gspro_host_address_; - std::string golfer_orientation_string_; + std::string golfer_orientation_string_ = "right_handed"; SystemMode system_mode_ = kTest; - LoggingLevel logging_level_ = kInfo; - ArtifactSaveLevel artifact_save_level_ = kNoArtifacts; + LoggingLevel logging_level_ = kWarn; + ArtifactSaveLevel artifact_save_level_ = kFinalResultsOnly; GolferOrientation golfer_orientation_ = kRightHanded; - bool wait_for_key_on_images_; + bool wait_for_key_on_images_ = false; bool run_single_pi_ = true; // Always true — dual-Pi support removed - bool show_images_; + bool show_images_ = false; unsigned int search_center_x_ = 0; unsigned int search_center_y_ = 0; double camera_gain_ = 0.0; // 1.0 might seem more appropriate, but we want to be able to see if this is set or not From 32eedb59df2494e6ce472b57a207fbb343bf6051 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 31 Jul 2026 18:29:46 -0400 Subject: [PATCH 070/156] import legacy json once at startup and only into empty tables --- Software/web-server/config_manager.py | 18 +-- .../tests/test_camera_autodetect.py | 16 ++- .../web-server/tests/test_config_manager.py | 109 ++++++++++-------- 3 files changed, 80 insertions(+), 63 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index fa7dceee..73caa1e2 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -5,8 +5,8 @@ 2. Calibration data: calibration table in the SQLite database 3. User overrides: settings table in the SQLite database (sparse) -~/.pitrac/config/user_settings.json and calibration_data.json are merged into -those tables on load and renamed to *.imported. +~/.pitrac/config/user_settings.json and calibration_data.json are imported once at +startup into whichever of those tables is still empty, then renamed to *.imported. """ import copy @@ -115,7 +115,9 @@ def expand_path(path_str: str) -> Path: self._settings = KeyValueRepository(self._db, "settings") self._calibration = KeyValueRepository(self._db, "calibration") - self.found_legacy_json = False + self.found_legacy_json = self.user_settings_path.exists() or self.calibration_data_path.exists() + self._import_json(self._settings, self.user_settings_path) + self._import_json(self._calibration, self.calibration_data_path) self.reload() def _load_raw_metadata(self) -> Dict[str, Any]: @@ -145,9 +147,6 @@ def reload(self) -> None: """Reload configuration from metadata, calibration data, and user settings""" with self._lock: self._metadata_cache = None - self.found_legacy_json |= self.user_settings_path.exists() or self.calibration_data_path.exists() - self._import_json(self._settings, self.user_settings_path) - self._import_json(self._calibration, self.calibration_data_path) self.user_settings = _unflatten(self._settings.load()) self.calibration_data = _unflatten(self._calibration.load()) # Build merged config from metadata defaults + calibration + user overrides @@ -174,9 +173,12 @@ def _load_json(self, path: Path) -> Optional[Dict[str, Any]]: return data if isinstance(data, dict) else None def _import_json(self, repo: KeyValueRepository, path: Path) -> None: - """Merge a legacy JSON file over the table rows (file keys win), then rename it""" + """Import a legacy JSON file into an empty table, then rename it""" if not path.exists(): return + if repo.load(): + logger.info(f"Not importing {path}: the {repo.table} table already has values") + return data = self._load_json(path) if data is None: @@ -189,7 +191,7 @@ def _import_json(self, repo: KeyValueRepository, path: Path) -> None: if dotted: logger.info(f"Dropping dotted keys from {path}: {', '.join(dotted)}") - repo.replace_all(_flatten(_deep_merge(_unflatten(repo.load()), data))) + repo.replace_all(_flatten(data)) target = path.with_name(path.name + ".imported") if target.exists(): diff --git a/Software/web-server/tests/test_camera_autodetect.py b/Software/web-server/tests/test_camera_autodetect.py index c284845e..09194479 100644 --- a/Software/web-server/tests/test_camera_autodetect.py +++ b/Software/web-server/tests/test_camera_autodetect.py @@ -31,13 +31,17 @@ async def test_existing_db_never_runs_detection(self, server_instance): detector.assert_not_called() - async def test_legacy_settings_file_skips_detection(self, server_instance): - manager = server_instance.config_manager - manager.user_settings_path.parent.mkdir(parents=True, exist_ok=True) - manager.user_settings_path.write_text('{"cameras.slot1.type": "5"}') - manager.reload() + async def test_legacy_settings_file_skips_detection(self, monkeypatch, tmp_path): + import server as server_module + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(server_module, "DB_PATH", tmp_path / "test.db") + legacy = tmp_path / ".pitrac" / "config" / "user_settings.json" + legacy.parent.mkdir(parents=True) + legacy.write_text('{"cameras": {"slot1": {"type": "5"}}}') + srv = server_module.PiTracServer() with patch("server.CameraDetector") as detector: - await server_instance._detect_cameras_if_unset() + await srv._detect_cameras_if_unset() detector.assert_not_called() diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index b2b758a4..56cb11aa 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -19,6 +19,14 @@ def db(tmp_path): d.close() +@pytest.fixture +def legacy_dir(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + d = tmp_path / ".pitrac" / "config" + d.mkdir(parents=True) + return d + + @pytest.fixture def config_manager(db, tmp_path): manager = ConfigManager(db) @@ -130,16 +138,14 @@ def test_set_and_get_config(self, config_manager): gain_value = config_manager.get_config("gs_config.cameras.kCamera1Gain") assert gain_value is not None - def test_invalid_json_file_is_left_alone(self, config_manager, db): - settings = KeyValueRepository(db, "settings") - settings.replace_all({"gs_config.cameras.kCamera1Gain": "3.0"}) - config_manager.user_settings_path.write_text("{ invalid json }") + def test_invalid_json_file_is_left_alone(self, db, legacy_dir): + (legacy_dir / "user_settings.json").write_text("{ invalid json }") - config_manager.reload() + manager = ConfigManager(db) - assert config_manager.user_settings_path.exists() - assert settings.load() == {"gs_config.cameras.kCamera1Gain": "3.0"} - assert config_manager.user_settings == {"gs_config": {"cameras": {"kCamera1Gain": "3.0"}}} + assert (legacy_dir / "user_settings.json").exists() + assert KeyValueRepository(db, "settings").load() == {} + assert manager.user_settings == {} def test_missing_file_handling(self, config_manager): """Test handling of missing config files""" @@ -193,12 +199,12 @@ def test_callback_fires_after_set_config(self, config_manager): assert seen == [("gs_config.cameras.kCamera1Gain", "3.5")] - def test_imports_json_files_once(self, config_manager, db, tmp_path): - config_manager.user_settings_path.write_text(json.dumps({ + def test_imports_json_files_once_at_startup(self, db, legacy_dir): + (legacy_dir / "user_settings.json").write_text(json.dumps({ "gs_config": {"golf_simulator_interfaces": {"GSPro": {"kGSProConnectAddress": "10.0.0.5"}}}, "cameras": {"slot1": {"type": "4"}}, })) - config_manager.calibration_data_path.write_text(json.dumps({ + (legacy_dir / "calibration_data.json").write_text(json.dumps({ "gs_config": {"cameras": {"kCamera1FocalLength": 5.9, "kCamera1Angles": [2.14, -26.42]}}, })) @@ -213,81 +219,86 @@ def test_imports_json_files_once(self, config_manager, db, tmp_path): settings = KeyValueRepository(db, "settings") calibration = KeyValueRepository(db, "calibration") - config_manager.reload() + manager = ConfigManager(db) assert settings.load() == expected_settings assert calibration.load() == expected_calibration - assert config_manager.get_config("cameras.slot1.type") == "4" - assert not config_manager.user_settings_path.exists() - assert not config_manager.calibration_data_path.exists() - assert (tmp_path / "user_settings.json.imported").exists() - assert (tmp_path / "calibration_data.json.imported").exists() + assert manager.get_config("cameras.slot1.type") == "4" + assert manager.found_legacy_json + assert not (legacy_dir / "user_settings.json").exists() + assert not (legacy_dir / "calibration_data.json").exists() + assert (legacy_dir / "user_settings.json.imported").exists() + assert (legacy_dir / "calibration_data.json.imported").exists() + + def test_reload_never_imports(self, config_manager, db): + config_manager.user_settings_path.write_text(json.dumps({"cameras": {"slot1": {"type": "5"}}})) config_manager.reload() - assert settings.load() == expected_settings - assert calibration.load() == expected_calibration + assert KeyValueRepository(db, "settings").load() == {} + assert config_manager.user_settings_path.exists() - def test_later_user_settings_file_merges_over_rows(self, config_manager, db, tmp_path): + def test_legacy_file_next_to_rows_is_not_imported(self, db, legacy_dir): settings = KeyValueRepository(db, "settings") - settings.replace_all({"cameras.slot1.type": "4", "gs_config.cameras.kCamera1Gain": "3.0"}) - config_manager.user_settings_path.write_text(json.dumps({"cameras": {"slot1": {"type": "5"}}})) + settings.replace_all({"cameras.slot1.type": "4"}) + (legacy_dir / "user_settings.json").write_text(json.dumps({"cameras": {"slot1": {"type": "5"}}})) - config_manager.reload() + manager = ConfigManager(db) - assert settings.load() == {"cameras.slot1.type": "5", "gs_config.cameras.kCamera1Gain": "3.0"} - assert not config_manager.user_settings_path.exists() + assert settings.load() == {"cameras.slot1.type": "4"} + assert manager.get_config("cameras.slot1.type") == "4" + assert (legacy_dir / "user_settings.json").exists() - def test_second_import_keeps_first_backup(self, config_manager, tmp_path): - first_backup = tmp_path / "user_settings.json.imported" + def test_second_import_keeps_first_backup(self, db, legacy_dir): + first_backup = legacy_dir / "user_settings.json.imported" first_backup.write_text('{"original": true}') - config_manager.user_settings_path.write_text("{}") + (legacy_dir / "user_settings.json").write_text("{}") - config_manager.reload() + ConfigManager(db) assert first_backup.read_text() == '{"original": true}' - assert len(list(tmp_path.glob("user_settings.json.imported.*"))) == 1 + assert len(list(legacy_dir.glob("user_settings.json.imported.*"))) == 1 - def test_import_drops_dotted_top_level_keys(self, config_manager, db): - config_manager.user_settings_path.write_text(json.dumps({ + def test_import_drops_dotted_top_level_keys(self, db, legacy_dir): + (legacy_dir / "user_settings.json").write_text(json.dumps({ "cameras": {"slot1": {"type": "5"}}, "cameras.slot1.type": "4", })) - config_manager.reload() + manager = ConfigManager(db) assert KeyValueRepository(db, "settings").load() == {"cameras.slot1.type": "5"} - assert config_manager.get_config("cameras.slot1.type") == "5" + assert manager.get_config("cameras.slot1.type") == "5" - def test_backups_from_the_same_second_are_all_kept(self, config_manager, tmp_path): + def test_backups_from_the_same_second_are_all_kept(self, db, legacy_dir): stamp = "user_settings.json.imported.20260101-120000" - (tmp_path / "user_settings.json.imported").write_text("first") - (tmp_path / stamp).write_text("second") - config_manager.user_settings_path.write_text("{}") + (legacy_dir / "user_settings.json.imported").write_text("first") + (legacy_dir / stamp).write_text("second") + (legacy_dir / "user_settings.json").write_text("{}") with patch("config_manager.datetime") as clock: clock.now.return_value = datetime(2026, 1, 1, 12, 0, 0) - config_manager.reload() + ConfigManager(db) - assert (tmp_path / "user_settings.json.imported").read_text() == "first" - assert (tmp_path / stamp).read_text() == "second" - assert (tmp_path / f"{stamp}.1").read_text() == "{}" + assert (legacy_dir / "user_settings.json.imported").read_text() == "first" + assert (legacy_dir / stamp).read_text() == "second" + assert (legacy_dir / f"{stamp}.1").read_text() == "{}" - def test_failed_rename_keeps_imported_rows_and_starts(self, config_manager, db): - config_manager.user_settings_path.write_text(json.dumps({"cameras": {"slot1": {"type": "5"}}})) + def test_failed_rename_keeps_imported_rows_and_starts(self, db, legacy_dir): + (legacy_dir / "user_settings.json").write_text(json.dumps({"cameras": {"slot1": {"type": "5"}}})) with patch.object(Path, "rename", side_effect=OSError("read-only")): - config_manager.reload() + manager = ConfigManager(db) assert KeyValueRepository(db, "settings").load() == {"cameras.slot1.type": "5"} - assert config_manager.get_config("cameras.slot1.type") == "5" + assert manager.get_config("cameras.slot1.type") == "5" - def test_import_drops_nested_dotted_keys(self, config_manager, db): - config_manager.user_settings_path.write_text(json.dumps({ + def test_import_drops_nested_dotted_keys(self, db, legacy_dir): + (legacy_dir / "user_settings.json").write_text(json.dumps({ "gs_config": {"cameras": {"kCamera1Gain": "2.5"}, "a.b": 1}, })) - config_manager.reload() + ConfigManager(db) assert KeyValueRepository(db, "settings").load() == {"gs_config.cameras.kCamera1Gain": "2.5"} From 0dedc2ae8ed69ba2b009ca34fd9295d12b83324a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 31 Jul 2026 19:15:11 -0400 Subject: [PATCH 071/156] show pitrac start refusals as errors on every page --- Software/web-server/static/js/common.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Software/web-server/static/js/common.js b/Software/web-server/static/js/common.js index 0c93f610..32f6d31e 100644 --- a/Software/web-server/static/js/common.js +++ b/Software/web-server/static/js/common.js @@ -65,11 +65,16 @@ async function controlPiTrac(action) { throw new Error(err.error || `Failed to ${action} PiTrac`); } const data = await res.json(); + if (data.status === 'error') throw new Error(data.message || `Failed to ${action} PiTrac`); if (typeof showStatusMessage === 'function') showStatusMessage(data.message, 'success'); setTimeout(() => { if (typeof checkSystemStatus === 'function') checkSystemStatus(); }, 2000); } catch (err) { console.error(`Error ${action}ing PiTrac:`, err); - if (typeof showStatusMessage === 'function') showStatusMessage(err.message, 'error'); + if (typeof showStatusMessage === 'function') { + showStatusMessage(err.message, 'error'); + } else { + alert(err.message); + } } finally { getButtons(action).forEach(b => b.classList.remove('loading')); setTimeout(() => { From ca01343185cc9a75979ee7469fcf66663db8e0f3 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 31 Jul 2026 19:50:00 -0400 Subject: [PATCH 072/156] test the still starting stop guard --- Software/web-server/tests/test_testing_tools_manager.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Software/web-server/tests/test_testing_tools_manager.py b/Software/web-server/tests/test_testing_tools_manager.py index 2a6ad623..c08155a1 100644 --- a/Software/web-server/tests/test_testing_tools_manager.py +++ b/Software/web-server/tests/test_testing_tools_manager.py @@ -302,6 +302,15 @@ async def test_stop_tool_not_running(self, testing_manager): assert result["status"] == "error" assert "not running" in result["message"] + @pytest.mark.asyncio + async def test_stop_tool_still_starting(self, testing_manager): + testing_manager.running_processes["pulse_test"] = None + + result = await testing_manager.stop_tool("pulse_test") + + assert result == {"status": "error", "message": "Tool pulse_test is still starting"} + assert testing_manager.running_processes == {"pulse_test": None} + @pytest.mark.asyncio async def test_stop_tool_success(self, testing_manager): """Test successfully stopping a running tool""" From a22a869aa7f5c60f4cb571bf63a58f105e5b3007 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 1 Aug 2026 19:04:11 -0400 Subject: [PATCH 073/156] use the real ball location system mode and drop the stale cli wording --- Software/web-server/configurations.json | 2 +- Software/web-server/testing_tools_manager.py | 4 ++-- .../tests/test_testing_tools_manager.py | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 259ace76..261975ba 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -482,7 +482,7 @@ "category": "Debugging", "subcategory": "advanced", "displayName": "Show Debug Images", - "description": "Display debug/trace images on screen for troubleshooting (CLI parameter)", + "description": "Display debug/trace images on screen for troubleshooting", "type": "boolean", "default": false, "requiresRestart": true diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index abdaaad9..fae103dd 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -69,7 +69,7 @@ def __init__(self, config_manager): "name": "Camera 1 Ball Location", "description": "Check ball location for Camera 1", "category": "calibration", - "args": ["--system_mode", "camera1", "--check_ball_location"], + "args": ["--system_mode", "camera1_ball_location"], "requires_sudo": False, "timeout": 10, }, @@ -77,7 +77,7 @@ def __init__(self, config_manager): "name": "Camera 2 Ball Location", "description": "Check ball location for Camera 2", "category": "calibration", - "args": ["--system_mode", "camera2", "--check_ball_location"], + "args": ["--system_mode", "camera2_ball_location"], "requires_sudo": False, "timeout": 10, }, diff --git a/Software/web-server/tests/test_testing_tools_manager.py b/Software/web-server/tests/test_testing_tools_manager.py index c08155a1..18766280 100644 --- a/Software/web-server/tests/test_testing_tools_manager.py +++ b/Software/web-server/tests/test_testing_tools_manager.py @@ -219,6 +219,21 @@ async def test_run_tool_with_sudo(self, testing_manager, mock_config_manager, tm assert args[0] == "sudo" assert "-E" in args + @pytest.mark.asyncio + @pytest.mark.parametrize("camera", ["camera1", "camera2"]) + async def test_ball_location_tool_uses_ball_location_mode(self, testing_manager, camera): + mock_process = AsyncMock() + mock_process.returncode = 0 + mock_process.communicate.return_value = (b"", b"") + + with patch("asyncio.create_subprocess_exec", return_value=mock_process) as mock_exec: + with patch.object(testing_manager, "_find_and_read_test_log", return_value=None): + await testing_manager.run_tool(f"{camera}_ball_location") + + args = mock_exec.call_args.args + assert args[1:3] == ("--system_mode", f"{camera}_ball_location") + assert "--check_ball_location" not in args + @pytest.mark.asyncio async def test_run_tool_exception(self, testing_manager, mock_config_manager): """Test exception handling during tool run""" From 212dc37af1f27abc20817b712256fb3ac21aa444 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 1 Aug 2026 21:25:00 -0400 Subject: [PATCH 074/156] color dashboard error messages --- Software/web-server/static/css/dashboard.css | 1 + 1 file changed, 1 insertion(+) diff --git a/Software/web-server/static/css/dashboard.css b/Software/web-server/static/css/dashboard.css index 5c6dbe84..cfc623b3 100644 --- a/Software/web-server/static/css/dashboard.css +++ b/Software/web-server/static/css/dashboard.css @@ -39,6 +39,7 @@ overflow: hidden; text-overflow: ellipsis; } +.status-strip-message.error { color: var(--color-error); } /* Status strip states */ .status-strip.initializing { border-left-color: var(--color-info); } From 2a27ad375aeec82fe2c69cd3f5155168864e6071 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 2 Aug 2026 15:12:45 -0400 Subject: [PATCH 075/156] keep dashboard status messages up until their timeout --- Software/web-server/static/js/dashboard.js | 54 +++++++++++++--------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/Software/web-server/static/js/dashboard.js b/Software/web-server/static/js/dashboard.js index 04d8def6..2efc92bb 100644 --- a/Software/web-server/static/js/dashboard.js +++ b/Software/web-server/static/js/dashboard.js @@ -94,7 +94,6 @@ function handleImageReady(filename) { function updateBallStatus(resultType, message, isPiTracRunning) { const strip = document.getElementById('status-strip'); const title = document.getElementById('status-strip-title'); - const msg = document.getElementById('status-strip-message'); const resetBtn = document.getElementById('btn-reset'); strip.classList.remove('initializing', 'waiting', 'stabilizing', 'ready', 'hit', 'error'); @@ -104,7 +103,7 @@ function updateBallStatus(resultType, message, isPiTracRunning) { if (isPiTracRunning === false) { strip.classList.add('error'); title.textContent = 'System Stopped'; - msg.textContent = 'PiTrac is not running \u2014 click Start to begin'; + setStripMessage('PiTrac is not running \u2014 click Start to begin'); return; } @@ -114,39 +113,39 @@ function updateBallStatus(resultType, message, isPiTracRunning) { if (normalizedType.includes('initializing')) { strip.classList.add('initializing'); title.textContent = 'System Initializing'; - msg.textContent = message || 'Starting up PiTrac system...'; + setStripMessage(message || 'Starting up PiTrac system...'); } else if (normalizedType.includes('waiting for ball')) { strip.classList.add('waiting'); title.textContent = 'Waiting for Ball'; - msg.textContent = message || 'Please place ball on tee'; + setStripMessage(message || 'Please place ball on tee'); } else if (normalizedType.includes('waiting for simulator')) { strip.classList.add('waiting'); title.textContent = 'Waiting for Simulator'; - msg.textContent = message || 'Waiting for simulator to be ready'; + setStripMessage(message || 'Waiting for simulator to be ready'); } else if (normalizedType.includes('pausing') || normalizedType.includes('stabilization')) { strip.classList.add('stabilizing'); title.textContent = 'Ball Detected'; - msg.textContent = message || 'Waiting for ball to stabilize...'; + setStripMessage(message || 'Waiting for ball to stabilize...'); } else if (normalizedType.includes('ball ready') || normalizedType.includes('ready')) { strip.classList.add('ready'); title.textContent = 'Ready to Hit!'; - msg.textContent = message || 'Ball is ready, take your shot'; + setStripMessage(message || 'Ball is ready, take your shot'); } else if (normalizedType.includes('hit')) { strip.classList.add('hit'); title.textContent = 'Ball Hit!'; - msg.textContent = message || 'Processing shot data...'; + setStripMessage(message || 'Processing shot data...'); resetBtn.style.display = ''; } else if (normalizedType.includes('error')) { strip.classList.add('error'); title.textContent = 'Error'; - msg.textContent = message || 'An error occurred'; + setStripMessage(message || 'An error occurred'); } else if (normalizedType.includes('multiple balls')) { strip.classList.add('error'); title.textContent = 'Multiple Balls Detected'; - msg.textContent = message || 'Please remove extra balls'; + setStripMessage(message || 'Please remove extra balls'); } else { title.textContent = 'System Status'; - msg.textContent = message || resultType; + setStripMessage(message || resultType); } } } @@ -193,18 +192,31 @@ const dashboardCheckPiTracStatus = async function() { return isRunning; }; +// While a showStatusMessage message is up, status updates are held and applied when it ends +let statusMessageTimer = null; +let heldStripMessage = ''; + +function setStripMessage(text) { + if (statusMessageTimer) { + heldStripMessage = text; + return; + } + document.getElementById('status-strip-message').textContent = text; +} + function showStatusMessage(message, type = 'info') { const statusMessage = document.getElementById('status-strip-message'); - if (statusMessage) { - const originalMessage = statusMessage.textContent; - statusMessage.textContent = message; - statusMessage.className = `status-strip-message ${type}`; - - setTimeout(() => { - statusMessage.textContent = originalMessage; - statusMessage.className = 'status-strip-message'; - }, 3000); - } + if (!statusMessage) return; + if (!statusMessageTimer) heldStripMessage = statusMessage.textContent; + clearTimeout(statusMessageTimer); + statusMessage.textContent = message; + statusMessage.className = `status-strip-message ${type}`; + + statusMessageTimer = setTimeout(() => { + statusMessageTimer = null; + statusMessage.textContent = heldStripMessage; + statusMessage.className = 'status-strip-message'; + }, 3000); } document.addEventListener('DOMContentLoaded', () => { From 87797d47cc690539e911e11ef3cf5d71cb8c0fe1 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 2 Aug 2026 20:53:02 -0400 Subject: [PATCH 076/156] treat any non-success pitrac control status as an error --- Software/web-server/static/js/common.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Software/web-server/static/js/common.js b/Software/web-server/static/js/common.js index 32f6d31e..2b144c3e 100644 --- a/Software/web-server/static/js/common.js +++ b/Software/web-server/static/js/common.js @@ -49,6 +49,9 @@ function getButtons(action) { .filter(Boolean); } +// Every other status from /api/pitrac/{start,stop,restart} is a failure or a refusal +const PITRAC_OK_STATUSES = ['started', 'already_running', 'stopped', 'not_running']; + async function controlPiTrac(action) { if ((action === 'start' || action === 'restart') && !(await requireStrobeSafe())) {return;} @@ -65,7 +68,7 @@ async function controlPiTrac(action) { throw new Error(err.error || `Failed to ${action} PiTrac`); } const data = await res.json(); - if (data.status === 'error') throw new Error(data.message || `Failed to ${action} PiTrac`); + if (!PITRAC_OK_STATUSES.includes(data.status)) throw new Error(data.message || `Failed to ${action} PiTrac`); if (typeof showStatusMessage === 'function') showStatusMessage(data.message, 'success'); setTimeout(() => { if (typeof checkSystemStatus === 'function') checkSystemStatus(); }, 2000); } catch (err) { From 155fe5562c14cb140c69135afa95fe1805630074 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 2 Aug 2026 22:35:56 -0400 Subject: [PATCH 077/156] drop the remaining cli parameter wording --- Software/web-server/configurations.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 261975ba..5ab53311 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -317,7 +317,7 @@ "subcategory": "basic", "basicSubcategory": "System", "displayName": "Golfer Orientation", - "description": "Golfer handedness - affects ball detection and swing analysis (CLI parameter)", + "description": "Golfer handedness - affects ball detection and swing analysis", "type": "select", "options": { "right_handed": "Right Handed", @@ -331,7 +331,7 @@ "subcategory": "basic", "basicSubcategory": "System", "displayName": "Practice Ball Mode", - "description": "Enable for lightweight practice/foam balls - adjusts detection parameters (CLI parameter)", + "description": "Enable for lightweight practice/foam balls - adjusts detection parameters", "type": "boolean", "default": false, "requiresRestart": true @@ -468,7 +468,7 @@ "subcategory": "basic", "basicSubcategory": "Storage", "displayName": "Diagnostic Image Level", - "description": "Control how many diagnostic images are saved (CLI parameter)", + "description": "Control how many diagnostic images are saved", "type": "select", "options": { "none": "None - No images saved", @@ -491,7 +491,7 @@ "category": "Debugging", "subcategory": "advanced", "displayName": "Pause on Debug Images", - "description": "Wait for keypress after showing debug images (CLI parameter)", + "description": "Wait for keypress after showing debug images", "type": "boolean", "default": false, "requiresRestart": true From b1340543f6a877668810361d60fac575f157989a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 4 Aug 2026 19:04:32 -0400 Subject: [PATCH 078/156] read the custom lens focal length and image dir from the keys the server actually serves --- Software/LMSourceCode/ImageProcessing/camera_hardware.cpp | 2 +- Software/LMSourceCode/ImageProcessing/lm_main.cpp | 1 + Software/web-server/pitrac_manager.py | 7 ++----- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp index c3cbac44..43adbfde 100644 --- a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp +++ b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp @@ -478,7 +478,7 @@ namespace golf_sim { // Only if we are using a custom focal length, then use that length if (lens_type == Lens_Custom) { - tag = "gs_config.cameras.kSlot" + std::to_string(camera_number_) + "CustomLensFocalLength"; + tag = "cameras.kSlot" + std::to_string(camera_number_) + "CustomLensFocalLength"; } else { // Otherwise, for non-custom lenses, just use the standard focal length parameter diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index f535adfd..4553d103 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1531,6 +1531,7 @@ int main(int argc, char *argv[]) if (!kBaseTestDir.empty() && kBaseTestDir.back() != '/') { kBaseTestDir += '/'; } + LoggingTools::kBaseImageLoggingDir = kBaseTestDir; // TBD - consider if there is a better place for this? diff --git a/Software/web-server/pitrac_manager.py b/Software/web-server/pitrac_manager.py index 57df534a..d7111786 100644 --- a/Software/web-server/pitrac_manager.py +++ b/Software/web-server/pitrac_manager.py @@ -85,15 +85,12 @@ async def start(self) -> Dict[str, Any]: Path(self.pid_file).parent.mkdir(parents=True, exist_ok=True) env = os.environ.copy() - home_dir = str(Path.home()) env["LD_LIBRARY_PATH"] = "/usr/lib/pitrac" env["PITRAC_ROOT"] = "/usr/lib/pitrac" env["OMP_WAIT_POLICY"] = "PASSIVE" - env["PITRAC_BASE_IMAGE_LOGGING_DIR"] = "~/LM_Shares/Images/".replace("~", home_dir) - env["PITRAC_WEBSERVER_SHARE_DIR"] = "~/LM_Shares/WebShare/".replace("~", home_dir) - Path(env["PITRAC_BASE_IMAGE_LOGGING_DIR"]).mkdir(parents=True, exist_ok=True) - Path(env["PITRAC_WEBSERVER_SHARE_DIR"]).mkdir(parents=True, exist_ok=True) + (Path.home() / "LM_Shares/Images").mkdir(parents=True, exist_ok=True) + (Path.home() / "LM_Shares/WebShare").mkdir(parents=True, exist_ok=True) cmd = self._build_command() From 36660770886e5675a5fb6d8457c6200ac862aa93 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 7 Aug 2026 09:01:01 -0400 Subject: [PATCH 079/156] drop the image dir env vars nothing reads --- .../LMSourceCode/ImageProcessing/logging_tools.cpp | 8 -------- Software/web-server/calibration_manager.py | 13 +------------ Software/web-server/testing_tools_manager.py | 2 -- 3 files changed, 1 insertion(+), 22 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/logging_tools.cpp b/Software/LMSourceCode/ImageProcessing/logging_tools.cpp index 2b25a6f7..42424445 100644 --- a/Software/LMSourceCode/ImageProcessing/logging_tools.cpp +++ b/Software/LMSourceCode/ImageProcessing/logging_tools.cpp @@ -69,11 +69,7 @@ namespace golf_sim { boost::circular_buffer LoggingTools::RecentLogMessages(20); -#ifdef __unix__ std::string LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#else - std::string LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#endif @@ -128,11 +124,7 @@ namespace golf_sim { kBaseImageLoggingDir = GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_; } else { -#ifdef __unix__ - LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#else LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#endif } ApplyLogLevel(); diff --git a/Software/web-server/calibration_manager.py b/Software/web-server/calibration_manager.py index 3183a79c..8663cd7a 100644 --- a/Software/web-server/calibration_manager.py +++ b/Software/web-server/calibration_manager.py @@ -709,7 +709,7 @@ def _build_web_server_args(self) -> list: return [f"--web_server_port={SERVER_PORT}"] def _build_environment(self, camera: str = "camera1") -> dict: - """Build environment variables from config + """Build environment variables for the pitrac_lm subprocess Args: camera: Which camera is being calibrated @@ -718,7 +718,6 @@ def _build_environment(self, camera: str = "camera1") -> dict: Environment dictionary with required variables """ env = os.environ.copy() - config = self.config_manager.get_config() # Set PITRAC_ROOT if not already set (required by camera discovery) if "PITRAC_ROOT" not in env: @@ -727,16 +726,6 @@ def _build_environment(self, camera: str = "camera1") -> dict: env.setdefault("LIBPISP_LOG_LEVEL", "4") env.setdefault("LIBCAMERA_LOG_LEVELS", "*:ERROR") - base_dir = config.get("gs_config", {}).get("logging", {}).get("kPCBaseImageLoggingDir", "~/LM_Shares/Images/") - env["PITRAC_BASE_IMAGE_LOGGING_DIR"] = str(base_dir).replace("~", str(Path.home())) - - web_share_dir = ( - config.get("gs_config", {}) - .get("ipc_interface", {}) - .get("kWebServerShareDirectory", "~/LM_Shares/WebShare/") - ) - env["PITRAC_WEBSERVER_SHARE_DIR"] = str(web_share_dir).replace("~", str(Path.home())) - return env async def _run_calibration_command(self, cmd: List[str], camera: str, timeout: int = 60) -> Dict[str, Any]: diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index fae103dd..fa8bceb7 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -200,8 +200,6 @@ async def run_tool(self, tool_id: str) -> Dict[str, Any]: env = os.environ.copy() env["LD_LIBRARY_PATH"] = "/usr/lib/pitrac" env["PITRAC_ROOT"] = "/usr/lib/pitrac" - env["PITRAC_BASE_IMAGE_LOGGING_DIR"] = base_image_dir - env["PITRAC_WEBSERVER_SHARE_DIR"] = str(Path.home() / "LM_Shares/WebShare") env["DISPLAY"] = ":0.0" env["OMP_WAIT_POLICY"] = "PASSIVE" From 8327e513fed83d1b03417336f02b05fcc33e2a91 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 7 Aug 2026 21:09:54 -0400 Subject: [PATCH 080/156] drop the unused ConfigurationManager and yaml-cpp --- .../ImageProcessing/ImageProcessing.vcxproj | 8 +- .../ImageProcessing.vcxproj.filters | 6 - .../ImageProcessing/configuration_manager.cpp | 465 ------------------ .../ImageProcessing/configuration_manager.h | 310 ------------ .../ImageProcessing/gs_config.cpp | 88 +--- .../LMSourceCode/ImageProcessing/meson.build | 4 +- Software/web-server/API_DOCUMENTATION.md | 9 - packaging/build.sh | 2 +- 8 files changed, 19 insertions(+), 873 deletions(-) delete mode 100644 Software/LMSourceCode/ImageProcessing/configuration_manager.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/configuration_manager.h diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj index 4b230000..edf038b8 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj @@ -131,9 +131,9 @@ Level3 true - _CRT_SECURE_NO_WARNINGS;BOOST_BIND_GLOBAL_PLACEHOLDERS;BOOST_ALL_DYN_LINK;BOOST_USE_WINAPI_VERSION=0x0A00;YAML_CPP_STATIC_DEFINE;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) + _CRT_SECURE_NO_WARNINGS;BOOST_BIND_GLOBAL_PLACEHOLDERS;BOOST_ALL_DYN_LINK;BOOST_USE_WINAPI_VERSION=0x0A00;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\yaml-cpp\include;E:\Dev_Libs\boost_1_90_0\boost;../packages/Microsoft.ML.OnnxRuntime.1.23.2/build/native/include + E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\boost_1_90_0\boost;../packages/Microsoft.ML.OnnxRuntime.1.23.2/build/native/include stdcpp20 OldStyle @@ -141,7 +141,7 @@ Console true E:\Dev_Libs\opencv\build\x64\vc16\lib;E:\Dev_Libs\boost_1_90_0\lib64-msvc-14.3 - E:\Dev_Libs\opencv\build\x64\vc16\lib\opencv_world4120d.lib;E:\dev_libs\yaml-cpp\build\Debug\yaml-cppd.lib;../packages/Microsoft.ML.OnnxRuntime.1.23.2/runtimes/win-x64/native/onnxruntime.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) + E:\Dev_Libs\opencv\build\x64\vc16\lib\opencv_world4120d.lib;../packages/Microsoft.ML.OnnxRuntime.1.23.2/runtimes/win-x64/native/onnxruntime.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) @@ -213,7 +213,6 @@ - true @@ -377,7 +376,6 @@ - true diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters index b0fe4924..ef5af351 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters @@ -249,9 +249,6 @@ Source Files - - Source Files - Source Files @@ -476,9 +473,6 @@ Header Files - - Header Files - Header Files diff --git a/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp b/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp deleted file mode 100644 index 8b168869..00000000 --- a/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp +++ /dev/null @@ -1,465 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include "configuration_manager.h" -#include "logging_tools.h" -#include -#include -#include -#include -#include - -namespace { - // Helper function to convert YAML to property tree - void yaml_to_ptree(const YAML::Node& node, boost::property_tree::ptree& pt, const std::string& key = "") { - if (node.IsScalar()) { - pt.put(key, node.as()); - } else if (node.IsSequence()) { - for (size_t i = 0; i < node.size(); ++i) { - yaml_to_ptree(node[i], pt, key + "." + std::to_string(i)); - } - } else if (node.IsMap()) { - for (const auto& it : node) { - std::string child_key = key.empty() ? it.first.as() : key + "." + it.first.as(); - yaml_to_ptree(it.second, pt, child_key); - } - } - } - - // Helper function to load YAML file to property tree - void load_yaml_to_ptree(const std::string& filename, boost::property_tree::ptree& pt) { - try { - YAML::Node yaml_node = YAML::LoadFile(filename); - yaml_to_ptree(yaml_node, pt); - } catch (const YAML::Exception& e) { - throw std::runtime_error("Failed to load YAML file: " + std::string(e.what())); - } - } -} - -namespace golf_sim { - -bool ConfigurationManager::Initialize( - const std::string& json_config_body, - const std::string& yaml_config_file, - const std::map& cli_overrides) { - - GS_LOG_TRACE_MSG(trace, "Initializing ConfigurationManager"); - - json_config_body_ = json_config_body; - - try { - std::istringstream body_stream(json_config_body); - boost::property_tree::read_json(body_stream, json_config_); - GS_LOG_MSG(info, "Loaded system defaults from web server config body"); - } catch (const boost::property_tree::json_parser_error& e) { - GS_LOG_MSG(error, "Failed to parse system config body: " + std::string(e.what())); - return false; - } - - // DEPRECATED: Support legacy YAML for migration period only - // This will be removed in future versions - if (!yaml_config_file.empty() && yaml_config_file != "none") { - std::string yaml_file = yaml_config_file; - if (yaml_file.empty()) { - // Check for legacy YAML in old locations - const std::vector yaml_locations = { - std::string(std::getenv("HOME") ? std::getenv("HOME") : "") + "/.pitrac/config/pitrac.yaml", - "/etc/pitrac/pitrac.yaml" - }; - - for (const auto& location : yaml_locations) { - if (std::filesystem::exists(location)) { - GS_LOG_MSG(warning, "Found legacy YAML config at: " + location); - GS_LOG_MSG(warning, "Please run 'pitrac config migrate-to-json' to convert to new format"); - break; - } - } - } - } - - // Apply CLI overrides - for (const auto& [key, value] : cli_overrides) { - SetOverride(key, value); - } - - // Check if a preset is specified - std::string preset = GetString("_preset", ""); - if (!preset.empty()) { - ApplyPreset(preset); - } - - return true; -} - -std::string ConfigurationManager::GetString(const std::string& key, const std::string& default_value) const { - return GetValue(key, default_value); -} - -int ConfigurationManager::GetInt(const std::string& key, int default_value) const { - std::string str_val = GetString(key, std::to_string(default_value)); - try { - return std::stoi(str_val); - } catch (...) { - return default_value; - } -} - -float ConfigurationManager::GetFloat(const std::string& key, float default_value) const { - std::string str_val = GetString(key, std::to_string(default_value)); - try { - return std::stof(str_val); - } catch (...) { - return default_value; - } -} - -bool ConfigurationManager::GetBool(const std::string& key, bool default_value) const { - std::string str_val = GetString(key, default_value ? "true" : "false"); - - // Handle various boolean representations - if (str_val == "true" || str_val == "1" || str_val == "yes" || str_val == "on") { - return true; - } - if (str_val == "false" || str_val == "0" || str_val == "no" || str_val == "off") { - return false; - } - - return default_value; -} - -void ConfigurationManager::SetOverride(const std::string& key, const std::string& value) { - std::lock_guard lock(config_mutex_); - SetInTree(cli_overrides_, key, value); -} - -bool ConfigurationManager::HasKey(const std::string& key) const { - if (GetFromTree(cli_overrides_, key).has_value()) return true; - if (GetFromTree(yaml_config_, key).has_value()) return true; - - std::string json_path = MapToJsonPath(key); - if (GetFromTree(json_config_, json_path).has_value()) return true; - - return false; -} - -std::string ConfigurationManager::GetJsonPath(const std::string& yaml_key) const { - return MapToJsonPath(yaml_key); -} - -bool ConfigurationManager::ApplyPreset(const std::string& preset_name) { - try { - auto preset = presets_.get_child_optional("presets." + preset_name); - if (!preset) { - GS_LOG_MSG(warning, "Preset not found: " + preset_name); - return false; - } - - GS_LOG_MSG(info, "Applying preset: " + preset_name); - - // Apply preset settings - auto settings = preset->get_child_optional("settings"); - if (settings) { - for (const auto& [key, value] : *settings) { - std::string val = value.get_value(); - SetInTree(yaml_config_, key, val); - } - } - - return true; - } catch (const std::exception& e) { - GS_LOG_MSG(error, "Failed to apply preset: " + std::string(e.what())); - return false; - } -} - -bool ConfigurationManager::ValidateConfiguration() const { - validation_errors_.clear(); - bool valid = true; - - // Validate all YAML settings against schema - for (const auto& [key, value] : yaml_config_) { - std::string str_val = value.get_value(); - if (!ValidateValue(key, str_val)) { - valid = false; - } - } - - // Validate CLI overrides - for (const auto& [key, value] : cli_overrides_) { - std::string str_val = value.get_value(); - if (!ValidateValue(key, str_val)) { - valid = false; - } - } - - return valid; -} - -std::vector ConfigurationManager::GetValidationErrors() const { - return validation_errors_; -} - -bool ConfigurationManager::ExportEffectiveConfig(const std::string& output_file, const std::string& format) const { - try { - boost::property_tree::ptree effective_config; - - // Build effective configuration by merging all tiers - // Start with JSON defaults - effective_config = json_config_; - - // Apply YAML overrides - for (const auto& [key, value] : yaml_config_) { - std::string json_path = MapToJsonPath(key); - SetInTree(effective_config, json_path, value.get_value()); - } - - // Apply CLI overrides - for (const auto& [key, value] : cli_overrides_) { - std::string json_path = MapToJsonPath(key); - SetInTree(effective_config, json_path, value.get_value()); - } - - // Export in requested format - if (format == "json") { - boost::property_tree::write_json(output_file, effective_config); - } else if (format == "yaml") { - // For now, export as JSON when yaml is requested - // TODO: Implement proper YAML export using yaml-cpp - GS_LOG_MSG(warning, "YAML export not yet implemented, exporting as JSON instead"); - boost::property_tree::write_json(output_file, effective_config); - } else { - GS_LOG_MSG(error, "Unknown export format: " + format); - return false; - } - - return true; - } catch (const std::exception& e) { - GS_LOG_MSG(error, "Failed to export configuration: " + std::string(e.what())); - return false; - } -} - -std::string ConfigurationManager::GetValueSource(const std::string& key) const { - if (GetFromTree(cli_overrides_, key).has_value()) { - return "cli"; - } - if (GetFromTree(yaml_config_, key).has_value()) { - return "yaml"; - } - - std::string json_path = MapToJsonPath(key); - if (GetFromTree(json_config_, json_path).has_value()) { - return "json"; - } - - return "not_found"; -} - -bool ConfigurationManager::Reload() { - GS_LOG_MSG(info, "Reloading configuration"); - - // Clear existing configuration - json_config_.clear(); - yaml_config_.clear(); - // Keep CLI overrides - - return Initialize(json_config_body_, yaml_config_file_, {}); -} - -bool ConfigurationManager::LoadMappings(const std::string& mappings_file) { - try { - load_yaml_to_ptree(mappings_file, mappings_); - - // Extract presets if present - auto presets = mappings_.get_child_optional("presets"); - if (presets) { - presets_ = *presets; - } - - // Build reverse mapping cache - BuildReverseMappingCache(); - - GS_LOG_MSG(info, "Loaded parameter mappings from: " + mappings_file); - return true; - } catch (const std::exception& e) { - GS_LOG_MSG(error, "Failed to load mappings: " + std::string(e.what())); - return false; - } -} - -std::string ConfigurationManager::MapToJsonPath(const std::string& yaml_key) const { - try { - // Look up mapping - auto mapping = mappings_.get_child_optional("mappings." + yaml_key); - if (mapping) { - auto json_path = mapping->get_optional("json_path"); - if (json_path) { - return *json_path; - } - } - } catch (...) { - // Ignore and return original key - } - - // No mapping found, return original key - return yaml_key; -} - -std::string ConfigurationManager::MapToYamlKey(const std::string& json_path) const { - // First check the cache - auto it = json_to_yaml_map_.find(json_path); - if (it != json_to_yaml_map_.end()) { - return it->second; - } - - // No mapping found, return original path - return json_path; -} - -void ConfigurationManager::BuildReverseMappingCache() { - json_to_yaml_map_.clear(); - - try { - auto mappings = mappings_.get_child_optional("mappings"); - if (!mappings) { - return; - } - - // Iterate through all mappings and build reverse map - for (const auto& [yaml_key, mapping_node] : *mappings) { - auto json_path = mapping_node.get_optional("json_path"); - if (json_path) { - // Store the reverse mapping: JSON path -> YAML key - json_to_yaml_map_[*json_path] = yaml_key; - GS_LOG_TRACE_MSG(trace, "Reverse mapping: " + *json_path + " -> " + yaml_key); - } - } - - GS_LOG_MSG(debug, "Built reverse mapping cache with " + std::to_string(json_to_yaml_map_.size()) + " entries"); - } catch (const std::exception& e) { - GS_LOG_MSG(warning, "Failed to build reverse mapping cache: " + std::string(e.what())); - } -} - -std::string ConfigurationManager::ConvertToJson(const std::string& yaml_key, const std::string& value) const { - try { - auto mapping = mappings_.get_child_optional("mappings." + yaml_key); - if (mapping) { - auto to_json = mapping->get_optional("to_json"); - if (to_json) { - // Simple conversion for boolean to "0"/"1" - if (*to_json == "value ? \"1\" : \"0\"") { - return (value == "true" || value == "1") ? "1" : "0"; - } - } - } - } catch (...) { - // Ignore and return original value - } - - return value; -} - -std::string ConfigurationManager::ConvertFromJson(const std::string& yaml_key, const std::string& value) const { - try { - auto mapping = mappings_.get_child_optional("mappings." + yaml_key); - if (mapping) { - auto from_json = mapping->get_optional("from_json"); - if (from_json) { - // Simple conversion for "0"/"1" to boolean - if (*from_json == "value == \"1\"") { - return value == "1" ? "true" : "false"; - } - } - } - } catch (...) { - // Ignore and return original value - } - - return value; -} - -std::string ConfigurationManager::ExpandPath(const std::string& path) const { - if (path.empty() || path[0] != '~') { - return path; - } - - const char* home = std::getenv("HOME"); - if (!home) { - return path; - } - - return std::string(home) + path.substr(1); -} - -bool ConfigurationManager::ValidateValue(const std::string& key, const std::string& value) const { - try { - auto mapping = mappings_.get_child_optional("mappings." + key); - if (!mapping) { - return true; // No validation rules, assume valid - } - - auto validation = mapping->get_child_optional("validation"); - if (!validation) { - return true; // No validation rules - } - - // Check enum values - auto enum_values = validation->get_child_optional("enum"); - if (enum_values) { - bool found = false; - for (const auto& [_, enum_val] : *enum_values) { - if (enum_val.get_value() == value) { - found = true; - break; - } - } - if (!found) { - validation_errors_.push_back(key + ": value '" + value + "' not in allowed values"); - return false; - } - } - - // Check min/max for numeric values - auto min = validation->get_optional("min"); - auto max = validation->get_optional("max"); - - if (min || max) { - try { - float num_val = std::stof(value); - - if (min && num_val < *min) { - validation_errors_.push_back(key + ": value " + value + " below minimum " + std::to_string(*min)); - return false; - } - - if (max && num_val > *max) { - validation_errors_.push_back(key + ": value " + value + " above maximum " + std::to_string(*max)); - return false; - } - } catch (...) { - validation_errors_.push_back(key + ": value '" + value + "' is not numeric"); - return false; - } - } - - // Check pattern (regex) - auto pattern = validation->get_optional("pattern"); - if (pattern) { - std::regex re(*pattern); - if (!std::regex_match(value, re)) { - validation_errors_.push_back(key + ": value '" + value + "' does not match pattern"); - return false; - } - } - - return true; - } catch (...) { - return true; // Ignore validation errors - } -} - -} // namespace golf_sim \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/configuration_manager.h b/Software/LMSourceCode/ImageProcessing/configuration_manager.h deleted file mode 100644 index 7c3c0bee..00000000 --- a/Software/LMSourceCode/ImageProcessing/configuration_manager.h +++ /dev/null @@ -1,310 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "logging_tools.h" - -namespace golf_sim { - -/** - * ConfigurationManager implements a three-tier configuration system: - * 1. Default values (built-in/JSON template) - * 2. User overrides (YAML configuration) - * 3. Runtime overrides (command-line arguments) - * - * The system maintains backward compatibility with golf_sim_config.json - * while providing user-friendly YAML configuration options. - */ -class ConfigurationManager { -public: - static ConfigurationManager& GetInstance() { - static ConfigurationManager instance; - return instance; - } - - // Delete copy constructor and assignment operator - ConfigurationManager(const ConfigurationManager&) = delete; - ConfigurationManager& operator=(const ConfigurationManager&) = delete; - - /** - * Initialize the configuration system - * @param json_config_body Config JSON served by the web server - * @param yaml_config_file Path to pitrac.yaml (user overrides) - * @param cli_overrides Command-line overrides - * @return true if initialization successful - */ - bool Initialize( - const std::string& json_config_body, - const std::string& yaml_config_file = "", - const std::map& cli_overrides = {} - ); - - /** - * Get a configuration value with fallback hierarchy - * @param key Configuration key (e.g., "ball_detection.method") - * @param default_value Default if not found - * @return Configuration value - */ - template - T GetValue(const std::string& key, const T& default_value) const; - - /** - * Get a configuration value as string - * @param key Configuration key - * @param default_value Default if not found - * @return Configuration value as string - */ - std::string GetString(const std::string& key, const std::string& default_value = "") const; - - /** - * Get a configuration value as integer - * @param key Configuration key - * @param default_value Default if not found - * @return Configuration value as integer - */ - int GetInt(const std::string& key, int default_value = 0) const; - - /** - * Get a configuration value as float - * @param key Configuration key - * @param default_value Default if not found - * @return Configuration value as float - */ - float GetFloat(const std::string& key, float default_value = 0.0f) const; - - /** - * Get a configuration value as boolean - * @param key Configuration key - * @param default_value Default if not found - * @return Configuration value as boolean - */ - bool GetBool(const std::string& key, bool default_value = false) const; - - /** - * Set a runtime override value - * @param key Configuration key - * @param value Override value - */ - void SetOverride(const std::string& key, const std::string& value); - - /** - * Check if a configuration key exists - * @param key Configuration key - * @return true if key exists in any tier - */ - bool HasKey(const std::string& key) const; - - /** - * Get the JSON configuration path for a YAML key - * @param yaml_key YAML configuration key - * @return JSON path if mapped, empty if not - */ - std::string GetJsonPath(const std::string& yaml_key) const; - - /** - * Apply a configuration preset - * @param preset_name Name of preset (e.g., "indoor", "putting") - * @return true if preset applied successfully - */ - bool ApplyPreset(const std::string& preset_name); - - /** - * Validate configuration against schema - * @return true if configuration is valid - */ - bool ValidateConfiguration() const; - - /** - * Get validation errors if any - * @return Vector of validation error messages - */ - std::vector GetValidationErrors() const; - - /** - * Export effective configuration (with all overrides applied) - * @param output_file Path to output file - * @param format Output format ("yaml" or "json") - * @return true if export successful - */ - bool ExportEffectiveConfig(const std::string& output_file, const std::string& format = "yaml") const; - - /** - * Get the source of a configuration value (for debugging) - * @param key Configuration key - * @return Source ("default", "json", "yaml", "cli", or "not_found") - */ - std::string GetValueSource(const std::string& key) const; - - /** - * Reload configuration files - * @return true if reload successful - */ - bool Reload(); - -private: - ConfigurationManager() = default; - ~ConfigurationManager() = default; - - // Configuration storage (three tiers) - boost::property_tree::ptree json_config_; // golf_sim_config.json - boost::property_tree::ptree yaml_config_; // pitrac.yaml overrides - boost::property_tree::ptree cli_overrides_; // Command-line overrides - boost::property_tree::ptree mappings_; // Parameter mappings - boost::property_tree::ptree presets_; // Configuration presets - - // Inputs for reloading - std::string yaml_config_file_; - std::string json_config_body_; - - // Thread safety - mutable std::mutex config_mutex_; - - // Validation errors - mutable std::vector validation_errors_; - - // Reverse mapping cache (JSON path -> YAML key) - mutable std::map json_to_yaml_map_; - - /** - * Load parameter mappings from file - * @param mappings_file Path to parameter-mappings.yaml - * @return true if successful - */ - bool LoadMappings(const std::string& mappings_file); - - /** - * Build reverse mapping cache from loaded mappings - */ - void BuildReverseMappingCache(); - - /** - * Map a YAML key to JSON path - * @param yaml_key YAML configuration key - * @return Mapped JSON path or original key if no mapping - */ - std::string MapToJsonPath(const std::string& yaml_key) const; - - /** - * Map a JSON path to YAML key (reverse mapping) - * @param json_path JSON configuration path - * @return Mapped YAML key or original path if no mapping - */ - std::string MapToYamlKey(const std::string& json_path) const; - - /** - * Convert YAML value to JSON format - * @param yaml_key YAML configuration key - * @param value Value to convert - * @return Converted value - */ - std::string ConvertToJson(const std::string& yaml_key, const std::string& value) const; - - /** - * Convert JSON value to YAML format - * @param yaml_key YAML configuration key - * @param value Value to convert - * @return Converted value - */ - std::string ConvertFromJson(const std::string& yaml_key, const std::string& value) const; - - /** - * Expand home directory in path - * @param path Path potentially containing ~ - * @return Expanded path - */ - std::string ExpandPath(const std::string& path) const; - - /** - * Validate a value against schema - * @param key Configuration key - * @param value Value to validate - * @return true if valid - */ - bool ValidateValue(const std::string& key, const std::string& value) const; - - /** - * Get value from property tree with dot notation support - * @param tree Property tree to search - * @param path Dot-separated path - * @return Value if found - */ - template - std::optional GetFromTree(const boost::property_tree::ptree& tree, const std::string& path) const; - - /** - * Set value in property tree with dot notation support - * @param tree Property tree to modify - * @param path Dot-separated path - * @param value Value to set - */ - template - void SetInTree(boost::property_tree::ptree& tree, const std::string& path, const T& value) const; -}; - -// Template implementation -template -T ConfigurationManager::GetValue(const std::string& key, const T& default_value) const { - std::lock_guard lock(config_mutex_); - - // Check CLI overrides first - if (auto val = GetFromTree(cli_overrides_, key)) { - return val.value(); - } - - // For YAML config, we need to handle both cases: - // 1. If the key is a JSON path (e.g., "gs_config.cameras.kCamera1Gain"), - // map it to YAML key and check - // 2. If the key is already a YAML key, check directly - - // Try to map JSON path to YAML key - std::string yaml_key = MapToYamlKey(key); - if (yaml_key != key) { - // It was a JSON path that got mapped to a YAML key - if (auto val = GetFromTree(yaml_config_, yaml_key)) { - return val.value(); - } - } - - // Also check with the original key in case it's already a YAML key - if (auto val = GetFromTree(yaml_config_, key)) { - return val.value(); - } - - // Map YAML key to JSON path and check JSON config - std::string json_path = MapToJsonPath(key); - if (auto val = GetFromTree(json_config_, json_path)) { - return val.value(); - } - - // Return default - return default_value; -} - -template -std::optional ConfigurationManager::GetFromTree(const boost::property_tree::ptree& tree, const std::string& path) const { - try { - return tree.get(path); - } catch (const boost::property_tree::ptree_error&) { - // Too Noise - Turn OFF - GS_LOG_MSG(trace, "GetFromTree failed for path: " + path); - return std::nullopt; - } -} - -template -void ConfigurationManager::SetInTree(boost::property_tree::ptree& tree, const std::string& path, const T& value) const { - tree.put(path, value); -} - -} // namespace golf_sim \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index 5056d0c3..d5bcf551 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -16,7 +16,6 @@ #include "gs_camera.h" #include "gs_ui_system.h" #include "gs_config.h" -#include "configuration_manager.h" #include "gs_options.h" // Having to set the constants in this way creates more entanglement than we'd like. TBD - Re-architect @@ -68,7 +67,6 @@ static void PopulateOptionsFromConfig() { bool GolfSimConfiguration::Initialize() { - // ConfigurationManager and configuration_root_ both parse this one body. std::string config_body; #ifdef __unix__ config_body = GsHttpClient::FetchConfig(); @@ -89,13 +87,6 @@ static void PopulateOptionsFromConfig() { return false; } - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (!config_mgr.Initialize(config_body)) { - GS_LOG_MSG(warning, "ConfigurationManager initialization failed, using JSON only"); - } else { - GS_LOG_MSG(info, "ConfigurationManager initialized with override support"); - } - // Read any values that we want to set early, here at initialization if (!ReadValues()) { return false; @@ -319,73 +310,22 @@ bool GolfSimConfiguration::ReadValues() { } - void GolfSimConfiguration::SetConstant(const std::string& tag_name, bool& constant_value) { - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (config_mgr.HasKey(tag_name)) { - constant_value = config_mgr.GetBool(tag_name, constant_value); - } else if (auto v = configuration_root_.get_optional(tag_name)) { - constant_value = *v; - } else { - GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, int& constant_value) { - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (config_mgr.HasKey(tag_name)) { - constant_value = config_mgr.GetInt(tag_name, constant_value); - } else if (auto v = configuration_root_.get_optional(tag_name)) { - constant_value = *v; - } else { - GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, long& constant_value) { - if (auto v = configuration_root_.get_optional(tag_name)) { - constant_value = *v; - } else { - GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, unsigned int& constant_value) { - if (auto v = configuration_root_.get_optional(tag_name)) { - constant_value = *v; - } else { - GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, float& constant_value) { - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (config_mgr.HasKey(tag_name)) { - constant_value = config_mgr.GetFloat(tag_name, constant_value); - } else if (auto v = configuration_root_.get_optional(tag_name)) { - constant_value = *v; - } else { - GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, double& constant_value) { - if (auto v = configuration_root_.get_optional(tag_name)) { - constant_value = *v; - } else { - GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); - } +template +static void ReadScalar(const boost::property_tree::ptree& root, const std::string& tag_name, T& constant_value) { + if (auto v = root.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); } +} - void GolfSimConfiguration::SetConstant(const std::string& tag_name, std::string& constant_value) { - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (config_mgr.HasKey(tag_name)) { - constant_value = config_mgr.GetString(tag_name, constant_value); - } else if (auto v = configuration_root_.get_optional(tag_name)) { - constant_value = *v; - } else { - GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); - } - } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, bool& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, int& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, long& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, unsigned int& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, float& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, double& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, std::string& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } void GolfSimConfiguration::SetConstant(const std::string& tag_name, cv::Vec3d& vec) { try { diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index f8684d3e..0eddde14 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -74,7 +74,6 @@ fmt_dep = dependency('fmt', required : true) boost_dep = dependency('boost', method: 'pkg-config', version : '>=1.74.0', modules : ['timer', 'log', 'thread', 'filesystem', 'regex']) -yamlcpp_dep = dependency('yaml-cpp', required : true) ncnn_dep = dependency('ncnn', required : true) omp_dep = dependency('openmp', language : 'cpp', required : false) if not omp_dep.found() @@ -95,7 +94,7 @@ conf_data = configuration_data() pitrac_lm_module_deps = [ libcamera_dep, thread_dep, opencv_dep, lgpio_dep, rpicam_app_dep, - fmt_dep, boost_dep, ssl_dep, yamlcpp_dep, ncnn_dep, omp_dep,] + fmt_dep, boost_dep, ssl_dep, ncnn_dep, omp_dep,] subdir('core') @@ -205,7 +204,6 @@ pitrac_lm_sources += ([ 'gs_options.cpp', 'gs_config.cpp', 'gs_shot_parameters.cpp', - 'configuration_manager.cpp', 'gs_sim_interface.cpp', 'gs_gspro_interface.cpp', 'gs_gspro_response.cpp', diff --git a/Software/web-server/API_DOCUMENTATION.md b/Software/web-server/API_DOCUMENTATION.md index 84b58f16..56df2021 100644 --- a/Software/web-server/API_DOCUMENTATION.md +++ b/Software/web-server/API_DOCUMENTATION.md @@ -121,15 +121,6 @@ Currently, the API does not implement authentication. Future versions may add se - API endpoints return appropriate HTTP status codes - WebSocket connections automatically handle disconnects -## Configuration -Configuration is loaded from `pitrac.yaml` with the following key network settings: -```yaml -network: - broker_address: tcp://localhost:61616 # ActiveMQ broker - username: pitrac_user # Optional credentials - password: secure_password # Optional credentials -``` - ## Recommended Clients - WebSocket support required - MsgPack decoding library recommended diff --git a/packaging/build.sh b/packaging/build.sh index 5d180d5a..e89f62e2 100755 --- a/packaging/build.sh +++ b/packaging/build.sh @@ -227,7 +227,7 @@ build_dev() { done # Boost libraries (dev packages pull in correct runtime versions automatically) - for pkg in libboost-dev libboost-all-dev libyaml-cpp-dev; do + for pkg in libboost-dev libboost-all-dev; do pkg_installed "$pkg" || missing_deps+=("$pkg") done From f3929dda4d9901047390923c0955df42a5f2500e Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 8 Aug 2026 08:13:05 -0400 Subject: [PATCH 081/156] drop the unused pitrac.yaml test fixture and the stale activemq api docs --- Software/web-server/API_DOCUMENTATION.md | 35 ++++++------------------ Software/web-server/tests/conftest.py | 23 ---------------- 2 files changed, 9 insertions(+), 49 deletions(-) diff --git a/Software/web-server/API_DOCUMENTATION.md b/Software/web-server/API_DOCUMENTATION.md index 56df2021..ac343797 100644 --- a/Software/web-server/API_DOCUMENTATION.md +++ b/Software/web-server/API_DOCUMENTATION.md @@ -74,15 +74,8 @@ Currently, the API does not implement authentication. Future versions may add se ```json { "status": "healthy", // Overall system status - "activemq_connected": true, // ActiveMQ broker connection - "activemq_running": true, // ActiveMQ service status "pitrac_running": true, // Main PiTrac service status - "websocket_clients": 3, // Active WebSocket connections - "listener_stats": { // ActiveMQ listener metrics - "connected": true, - "messages_processed": 42, - "errors": 0 - } + "websocket_clients": 3 // Active WebSocket connections } ``` @@ -93,28 +86,19 @@ Currently, the API does not implement authentication. Future versions may add se ```json { "websocket_connections": 3, - "listener": { - "connected": true, - "messages_processed": 42, - "errors": 0 - }, + "transport": "http", "shot_history_count": 25 } ``` -## ActiveMQ Message Format +## Shot Result Ingest -### Message Topics -- Primary Topic: `/topic/Golf.Sim` -- Message Encoding: MsgPack -- Supported Formats: - 1. Array Format: `[speed, launch_angle, side_angle, ...]` - 2. Dictionary Format: Partial or complete shot data update - -### Message Processing -- Base64 encoding supported -- Validation performed on each incoming message -- Errors logged but do not interrupt message processing +### Internal Endpoint +- **POST** `/api/internal/shot-result` +- **Description**: `pitrac_lm` posts each result here as JSON; the server stores it and broadcasts it to WebSocket clients +- **Fields**: `result_type` (int), `speed_mps`, `carry`, `launch_angle`, `side_angle`, `back_spin`, `side_spin`, `message`, `shot_id`, `images` +- Status messages keep the previous shot's numbers and only update `result_type` and `message` +- Hits are saved to shot history ## Error Handling - Most errors are logged internally @@ -123,7 +107,6 @@ Currently, the API does not implement authentication. Future versions may add se ## Recommended Clients - WebSocket support required -- MsgPack decoding library recommended - Supports both real-time and polling access patterns ## Limitations diff --git a/Software/web-server/tests/conftest.py b/Software/web-server/tests/conftest.py index 239f7b57..1b9db428 100644 --- a/Software/web-server/tests/conftest.py +++ b/Software/web-server/tests/conftest.py @@ -88,29 +88,6 @@ def shot_data_instance(): ) -@pytest.fixture -def mock_home_dir(tmp_path): - """Mock home directory for testing with simplified structure""" - from utils.test_helpers import ConfigTestHelper - - home = ConfigTestHelper.create_temp_config_dir() - - config = { - "network": { - "username": "test_user", - "password": "test_pass", - } - } - - config_file = home / ".pitrac" / "config" / "pitrac.yaml" - import yaml - - with open(config_file, "w") as f: - yaml.dump(config, f) - - yield home - - @pytest.fixture def mock_websocket(): """Mock WebSocket for testing using factory""" From 0a23b907d014a5bc3426cd027ee3245690060fd4 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 8 Aug 2026 21:25:40 -0400 Subject: [PATCH 082/156] define every key the c++ reads in configurations.json and test it --- Software/web-server/configurations.json | 450 ++++++++++++++++++ Software/web-server/tests/test_config_keys.py | 45 ++ 2 files changed, 495 insertions(+) create mode 100644 Software/web-server/tests/test_config_keys.py diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 5ab53311..1500259c 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -654,6 +654,24 @@ "default": 0.0, "requiresRestart": false }, + "gs_config.cameras.kHLAOffsetAngleDegrees": { + "category": "Cameras", + "subcategory": "advanced", + "displayName": "HLA Offset Angle (degrees)", + "description": "Read but not applied to results. The HLA correction applied to shots is HLA Offset.", + "type": "number", + "default": 0.0, + "requiresRestart": true + }, + "gs_config.cameras.kVLAOffsetAngleDegrees": { + "category": "Cameras", + "subcategory": "advanced", + "displayName": "VLA Offset Angle (degrees)", + "description": "Read but not applied to results. The VLA correction applied to shots is VLA Offset.", + "type": "number", + "default": 0.0, + "requiresRestart": true + }, "gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress": { "category": "Simulators", "subcategory": "basic", @@ -917,6 +935,15 @@ "default": "~/LM_Shares/Images/", "requiresRestart": false }, + "gs_config.logging.kPCBaseImageLoggingDir": { + "category": "Logging", + "subcategory": "advanced", + "displayName": "PC Image Logging Directory", + "description": "Base image logging directory on Windows builds. Linux builds use Image Directory (kLinuxBaseImageLoggingDir).", + "type": "string", + "default": "", + "requiresRestart": true + }, "gs_config.ipc_interface.kWebServerShareDirectory": { "category": "Network", "subcategory": "basic", @@ -1168,6 +1195,15 @@ "default": 15000, "requiresRestart": true }, + "gs_config.cameras.kCameraMotionDetectSettings": { + "category": "Cameras", + "subcategory": "advanced", + "displayName": "Camera Motion Detect Settings", + "description": "Path to the libcamera motion detect settings file. Not used by the current capture path.", + "type": "string", + "default": "./assets/motion_detect.json", + "requiresRestart": true + }, "gs_config.physical_constants.kBallRadiusMeters": { "category": "System", "subcategory": "advanced", @@ -3263,6 +3299,420 @@ "category": "testing", "ui_name": "External Ball ID Canny Upper" }, + "gs_config.testing.kCamera2StrobedEnvironmentGain": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Camera 2 Strobed Environment Gain", + "description": "Camera 2 gain in an externally strobed environment", + "type": "number", + "default": 0.8, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCircleCannyLower": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Canny Lower", + "description": "Canny lower threshold for best-circle detection with external strobes", + "type": "number", + "default": 55, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCircleCannyUpper": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Canny Upper", + "description": "Canny upper threshold for best-circle detection with external strobes", + "type": "number", + "default": 110, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCircleHoughDpParam1": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Hough DP", + "description": "Hough dp for best-circle detection with external strobes", + "type": "number", + "default": 1.5, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCircleParam1": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Param1", + "description": "Hough param1 for best-circle detection with external strobes", + "type": "number", + "default": 120.0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCircleParam2": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Param2", + "description": "Hough param2 for best-circle detection with external strobes", + "type": "number", + "default": 35.0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCirclePreCannyBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Pre-Canny Blur", + "description": "Pre-Canny blur size for best-circle detection with external strobes", + "type": "integer", + "default": 5, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCirclePreHoughBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Pre-Hough Blur", + "description": "Pre-Hough blur size for best-circle detection with external strobes", + "type": "integer", + "default": 13, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedCLAHEClipLimit": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe CLAHE Clip Limit", + "description": "CLAHE clip limit with external strobes", + "type": "integer", + "default": 6, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedCLAHETilesGridSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe CLAHE Tile Grid Size", + "description": "CLAHE tile grid size with external strobes", + "type": "integer", + "default": 6, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvBottomIgnoreHeight": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Bottom Ignore Height", + "description": "Pixels at the bottom of the image blacked out with external strobes", + "type": "integer", + "default": 70, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvCannyLower": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Canny Lower", + "description": "Canny lower threshold with external strobes", + "type": "number", + "default": 35, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvCannyUpper": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Canny Upper", + "description": "Canny upper threshold with external strobes", + "type": "number", + "default": 80, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvCurrentParam1": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Hough Param1", + "description": "Hough param1 with external strobes", + "type": "number", + "default": 130.0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvLowerH": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Lower H", + "description": "Lower hue for the external strobe HSV filter", + "type": "integer", + "default": 14, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvLowerS": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Lower S", + "description": "Lower saturation for the external strobe HSV filter", + "type": "integer", + "default": 26, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvLowerV": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Lower V", + "description": "Lower value for the external strobe HSV filter", + "type": "integer", + "default": 114, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvUpperH": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Upper H", + "description": "Upper hue for the external strobe HSV filter", + "type": "integer", + "default": 48, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvUpperS": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Upper S", + "description": "Upper saturation for the external strobe HSV filter", + "type": "integer", + "default": 255, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvUpperV": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Upper V", + "description": "Upper value for the external strobe HSV filter", + "type": "integer", + "default": 255, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterImage": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Filter Image", + "description": "Apply the HSV filter to images taken with external strobes", + "type": "boolean", + "default": true, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvHoughDpParam1": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Hough DP", + "description": "Hough dp with external strobes", + "type": "number", + "default": 1.0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvHoughLineIntersections": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Hough Line Intersections", + "description": "Hough line intersection threshold with external strobes", + "type": "integer", + "default": 235, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvLinesAngleLower": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Lines Angle Lower", + "description": "Lower line angle with external strobes", + "type": "integer", + "default": 140, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvLinesAngleUpper": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Lines Angle Upper", + "description": "Upper line angle with external strobes", + "type": "integer", + "default": 180, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMaxHoughReturnCircles": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Max Hough Circles", + "description": "Maximum Hough circles returned with external strobes", + "type": "integer", + "default": 20, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMaxParam2": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Max Param2", + "description": "Maximum Hough param2 with external strobes", + "type": "number", + "default": 100, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMaximumHoughLineGap": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Max Hough Line Gap", + "description": "Maximum Hough line gap with external strobes", + "type": "integer", + "default": 7, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMaximumSearchRadius": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Max Search Radius", + "description": "Maximum ball search radius in pixels with external strobes", + "type": "integer", + "default": 80, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMinHoughReturnCircles": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Min Hough Circles", + "description": "Minimum Hough circles returned with external strobes", + "type": "integer", + "default": 3, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMinimumHoughLineLength": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Min Hough Line Length", + "description": "Minimum Hough line length with external strobes", + "type": "integer", + "default": 23, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMinimumSearchRadius": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Min Search Radius", + "description": "Minimum ball search radius in pixels with external strobes", + "type": "integer", + "default": 60, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvNarrowingDpParam": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Narrowing DP", + "description": "Hough dp for the narrowing pass with external strobes", + "type": "number", + "default": 1.1, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvNarrowingParam2": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Narrowing Param2", + "description": "Hough param2 for the narrowing pass with external strobes", + "type": "number", + "default": 0.6, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvNarrowingPreCannyBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Narrowing Pre-Canny Blur", + "description": "Pre-Canny blur size for the narrowing pass with external strobes", + "type": "integer", + "default": 3, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvNarrowingPreHoughBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Narrowing Pre-Hough Blur", + "description": "Pre-Hough blur size for the narrowing pass with external strobes", + "type": "integer", + "default": 9, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvNumber_bits_for_fast_on_pulse_": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Fast Pulse On Bits", + "description": "Bits per fast strobe on-pulse in comparison mode", + "type": "integer", + "default": 0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvParam2Increment": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Param2 Increment", + "description": "Hough param2 step with external strobes", + "type": "number", + "default": 4, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Pre-Canny Blur", + "description": "Pre-Canny blur size with external strobes", + "type": "integer", + "default": 3, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Pre-Hough Blur", + "description": "Pre-Hough blur size with external strobes", + "type": "integer", + "default": 11, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvStartingParam2": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Starting Param2", + "description": "Starting Hough param2 with external strobes", + "type": "number", + "default": 65, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedUseCLAHEProcessing": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Use CLAHE", + "description": "Apply CLAHE to images taken with external strobes", + "type": "boolean", + "default": true, + "requiresRestart": true + }, + "gs_config.testing.kInterShotInjectionPauseSeconds": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Inter-Shot Injection Pause (s)", + "description": "Seconds between injected test shots", + "type": "integer", + "default": 0, + "requiresRestart": true + }, + "gs_config.testing.kTwoImageTestPreImage": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Two Image Test Pre Image", + "description": "Pre-image file for the two image test, relative to the test image directory", + "type": "string", + "default": "", + "requiresRestart": true + }, + "gs_config.testing.kTwoImageTestStrobedBallImage": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Two Image Test Strobed Ball Image", + "description": "Strobed ball image file for the two image test, relative to the test image directory", + "type": "string", + "default": "", + "requiresRestart": true + }, + "gs_config.testing.kTwoImageTestTeedBallImage": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Two Image Test Teed Ball Image", + "description": "Teed ball image file for the two image test, relative to the test image directory", + "type": "string", + "default": "", + "requiresRestart": true + }, "gs_config.cameras.kCamera1CalibrationMatrix": { "type": "array", "default": [ diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py new file mode 100644 index 00000000..f195dd21 --- /dev/null +++ b/Software/web-server/tests/test_config_keys.py @@ -0,0 +1,45 @@ +"""Every config key pitrac_lm reads must be defined in configurations.json.""" + +import json +import re +from pathlib import Path + +HERE = Path(__file__).parent +CPP_DIR = HERE / "../../LMSourceCode/ImageProcessing" +SCHEMA = HERE.parent / "configurations.json" + +# Tags the C++ builds at runtime (gs_config.cpp GetConfigString/apply_enum, camera_hardware.cpp) +DYNAMIC_KEYS = { + "cameras.slot1.type", + "cameras.slot1.lens", + "cameras.slot1.orientation", + "cameras.slot2.type", + "cameras.slot2.lens", + "cameras.slot2.orientation", + "logging.level", + "gs_config.player.kGolferOrientation", + "gs_config.logging.kArtifactSaveLevel", + "gs_config.cameras.kCamera1FocalLength", + "gs_config.cameras.kCamera2FocalLength", + "gs_config.cameras.kCamera1Angles", + "gs_config.cameras.kCamera2Angles", + "gs_config.cameras.kCamera1CalibrationMatrix", + "gs_config.cameras.kCamera2CalibrationMatrix", + "gs_config.cameras.kCamera1DistortionVector", + "gs_config.cameras.kCamera2DistortionVector", + "cameras.kSlot1CustomLensFocalLength", + "cameras.kSlot2CustomLensFocalLength", +} + + +def test_every_key_the_cpp_reads_is_in_the_schema(): + literal_keys = { + key + for path in CPP_DIR.glob("*.cpp") + for key in re.findall(r'SetConstant\(\s*"([^"]+)"\s*,', path.read_text(errors="replace")) + } + assert len(literal_keys) > 200 + + settings = json.loads(SCHEMA.read_text())["settings"] + missing = sorted((literal_keys | DYNAMIC_KEYS) - settings.keys()) + assert missing == [] From 1099edbc89d9f5d75a175606248379f9aa5e7103 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 9 Aug 2026 00:01:02 -0400 Subject: [PATCH 083/156] warn when the server omits a config key and drop the schema keys nothing reads --- .../ImageProcessing/gs_config.cpp | 14 +- .../LMSourceCode/ImageProcessing/lm_main.cpp | 6 +- Software/web-server/configurations.json | 254 ------------------ 3 files changed, 11 insertions(+), 263 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index d5bcf551..0fb2bed0 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -310,14 +310,14 @@ bool GolfSimConfiguration::ReadValues() { } -template -static void ReadScalar(const boost::property_tree::ptree& root, const std::string& tag_name, T& constant_value) { - if (auto v = root.get_optional(tag_name)) { - constant_value = *v; - } else { - GS_LOG_TRACE_MSG(trace, "No config value for " + tag_name + ", keeping default"); + template + static void ReadScalar(const boost::property_tree::ptree& root, const std::string& tag_name, T& constant_value) { + if (auto v = root.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_MSG(warning, "No config value for " + tag_name + ", keeping default"); + } } -} void GolfSimConfiguration::SetConstant(const std::string& tag_name, bool& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } void GolfSimConfiguration::SetConstant(const std::string& tag_name, int& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 4553d103..972fd33e 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1553,8 +1553,10 @@ int main(int argc, char *argv[]) // If the board has been calibrated, there will be a value // in the config - int connector_board_dac_setting = -1; - GolfSimConfiguration::SetConstant("gs_config.strobing.kDAC_setting", connector_board_dac_setting); + int connector_board_dac_setting = -1; + if (GolfSimConfiguration::PropertyExists("gs_config.strobing.kDAC_setting")) { + GolfSimConfiguration::SetConstant("gs_config.strobing.kDAC_setting", connector_board_dac_setting); + } if (connector_board_dac_setting < 0) { GS_LOG_MSG(error, "PiTrac is using a Version 3 Control Board, but the board does not appear to have been calibrated. Shutting down."); diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 1500259c..9072b0a0 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -954,28 +954,6 @@ "default": "~/LM_Shares/Images/", "requiresRestart": false }, - "gs_config.ipc_interface.kWebServerTomcatShareDirectory": { - "category": "Network", - "subcategory": "basic", - "basicSubcategory": "Storage", - "displayName": "Tomcat Share Directory", - "description": "Directory for Tomcat web server files", - "type": "path", - "default": "~/LM_Shares/WebShare", - "requiresRestart": false - }, - "gs_config.ipc_interface.kRefreshTimeSeconds": { - "category": "Network", - "subcategory": "basic", - "basicSubcategory": "Network", - "displayName": "Web Refresh Interval", - "description": "Web interface refresh interval in seconds", - "type": "number", - "min": 1, - "max": 60, - "default": 3, - "requiresRestart": false - }, "gs_config.golf_simulator_interfaces.kSkipSpinCalculation": { "category": "Spin Analysis", "subcategory": "basic", @@ -986,16 +964,6 @@ "default": false, "requiresRestart": false }, - "gs_config.golf_simulator_interfaces.kWriteSpinAnalysisCsvFiles": { - "category": "Spin Analysis", - "subcategory": "basic", - "basicSubcategory": "Spin", - "displayName": "Write Spin CSV Files", - "description": "Save spin analysis data to CSV files", - "type": "boolean", - "default": true, - "requiresRestart": false - }, "gs_config.ball_identification.kModelConfidenceThreshold": { "category": "AI Detection", "subcategory": "advanced", @@ -1299,17 +1267,6 @@ "default": 0.0, "requiresRestart": false }, - "gs_config.ball_position.kMinBallRadiusPixelsForProximityWarning": { - "category": "Ball Detection", - "subcategory": "advanced", - "displayName": "Min Ball Radius for Proximity Warning", - "description": "Minimum ball radius in pixels to trigger proximity warning", - "type": "number", - "min": 50, - "max": 300, - "default": 160, - "requiresRestart": false - }, "gs_config.strobing.kBaudRateForFastPulses": { "category": "Strobing", "subcategory": "advanced", @@ -1421,17 +1378,6 @@ "default": 300, "requiresRestart": true }, - "gs_config.strobing.kPauseBeforeSendingFinalImageTriggerMs": { - "category": "Strobing", - "subcategory": "advanced", - "displayName": "Pause Before Final Image Trigger (ms)", - "description": "Pause before sending final image trigger in milliseconds", - "type": "number", - "min": 500, - "max": 5000, - "default": 2000, - "requiresRestart": true - }, "gs_config.strobing.kPauseToSetUpInnoMakerExternalTriggerMilliseconds": { "category": "Strobing", "subcategory": "advanced", @@ -1672,15 +1618,6 @@ "default": false, "requiresRestart": true }, - "gs_config.club_data.kClubImageOutputDir": { - "category": "Storage", - "subcategory": "advanced", - "displayName": "Club Image Output Directory", - "description": "Directory for saving club images", - "type": "path", - "default": "~/LM_Shares/Images/", - "requiresRestart": false - }, "gs_config.club_data.kNumberFramesToSaveBeforeHit": { "category": "Club Data", "subcategory": "advanced", @@ -2708,17 +2645,6 @@ "default": 0.9, "requiresRestart": false }, - "gs_config.ball_identification.kPlacedNarrowingParam1": { - "category": "Ball Detection", - "subcategory": "advanced", - "displayName": "Placed Narrowing Param1", - "description": "Canny edge threshold for placed narrowing", - "type": "number", - "min": 100, - "max": 500, - "default": 300, - "requiresRestart": false - }, "gs_config.ball_identification.kPlacedNarrowingRadiiDpParam": { "category": "Ball Detection", "subcategory": "advanced", @@ -3102,40 +3028,6 @@ "default": 0, "requiresRestart": true }, - "gs_config.strobing.kLastPulsePutterRepeats": { - "category": "Strobing", - "subcategory": "advanced", - "displayName": "Last Pulse Putter Repeats", - "description": "Number of times to repeat last pulse in putting mode", - "type": "number", - "min": 0, - "max": 10, - "default": 0, - "requiresRestart": true - }, - "gs_config.strobing.kLongerStrobePulseVectorDriver": { - "category": "Strobing", - "subcategory": "advanced", - "displayName": "Longer Strobe Pulse Vector Driver", - "description": "Extended strobe timing vector for driver shots", - "type": "array", - "default": [ - 0.175, - 0.7, - 1.4, - 2.45, - 1.26, - 2.8, - 2.1, - 3.15, - 3.85, - 3.85, - 10.4, - 3.5, - 0 - ], - "requiresRestart": true - }, "gs_config.strobing.kDynamicFollowOnPulseVectorPutter": { "category": "Strobing", "subcategory": "advanced", @@ -3169,17 +3061,6 @@ "default": 4, "requiresRestart": true }, - "gs_config.user_interface.kRefreshTimeSeconds": { - "category": "Network", - "subcategory": "advanced", - "displayName": "UI Refresh Time", - "description": "User interface refresh interval in seconds", - "type": "number", - "min": 1, - "max": 30, - "default": 3, - "requiresRestart": false - }, "gs_config.testing.kAutomatedTestExpectedResultsCSV": { "type": "string", "default": "Uneekor Comparison 2025-02-07_Small_Test.csv", @@ -3243,62 +3124,6 @@ "category": "testing", "ui_name": "Test Image Directory" }, - "gs_config.testing.kExternallyStrobedALTBestCircleCannyLower": { - "type": "integer", - "default": 35, - "description": "Canny lower threshold for externally strobed ALT best circle", - "category": "testing", - "ui_name": "External Strobe Canny Lower" - }, - "gs_config.testing.kExternallyStrobedALTBestCircleCannyUpper": { - "type": "integer", - "default": 80, - "description": "Canny upper threshold for externally strobed ALT best circle", - "category": "testing", - "ui_name": "External Strobe Canny Upper" - }, - "gs_config.testing.kExternallyStrobedALTBestCircleHoughDpParam1": { - "type": "float", - "default": 1.3, - "description": "Hough DP parameter 1 for externally strobed ALT best circle", - "category": "testing", - "ui_name": "External Strobe Hough DP1" - }, - "gs_config.testing.kExternallyStrobedALTBestCircleHoughParam2": { - "type": "integer", - "default": 13, - "description": "Hough parameter 2 for externally strobed ALT best circle", - "category": "testing", - "ui_name": "External Strobe Hough P2" - }, - "gs_config.testing.kExternallyStrobedALTBestCircleMaxIterations": { - "type": "integer", - "default": 25, - "description": "Max iterations for externally strobed ALT best circle", - "category": "testing", - "ui_name": "External Strobe Max Iterations" - }, - "gs_config.testing.kExternallyStrobedALTBestCircleMinInliers": { - "type": "integer", - "default": 25, - "description": "Min inliers for externally strobed ALT best circle", - "category": "testing", - "ui_name": "External Strobe Min Inliers" - }, - "gs_config.testing.kExternallyStrobedBallIdentificationCannyLower": { - "type": "integer", - "default": 35, - "description": "Canny lower threshold for externally strobed ball identification", - "category": "testing", - "ui_name": "External Ball ID Canny Lower" - }, - "gs_config.testing.kExternallyStrobedBallIdentificationCannyUpper": { - "type": "integer", - "default": 80, - "description": "Canny upper threshold for externally strobed ball identification", - "category": "testing", - "ui_name": "External Ball ID Canny Upper" - }, "gs_config.testing.kCamera2StrobedEnvironmentGain": { "category": "testing", "subcategory": "advanced", @@ -3785,78 +3610,6 @@ "category": "calibration", "ui_name": "Camera 2 Distortion Vector" }, - "gs_config.cameras.3_6Lens_kCamera1CalibrationMatrix": { - "type": "array", - "default": [ - [ - "1748.506644661262953", - "0.0", - "632.5374407393054526" - ], - [ - "0.0", - "1743.341748687922745", - "407.5677927449370941" - ], - [ - "0.0", - "0.0", - "1.0" - ] - ], - "description": "Camera 1 calibration matrix for 3.6mm lens", - "category": "calibration", - "ui_name": "Camera 1 3.6mm Lens Matrix" - }, - "gs_config.cameras.3_6Lens_kCamera1DistortionVector": { - "type": "array", - "default": [ - "-0.5323350228082535107", - "0.3171607772519656199", - "0.0007282375091653870043", - "-0.002130459534525419", - "0.0" - ], - "description": "Camera 1 distortion for 3.6mm lens", - "category": "calibration", - "ui_name": "Camera 1 3.6mm Lens Distortion" - }, - "gs_config.cameras.3_6Lens_kCamera2CalibrationMatrix": { - "type": "array", - "default": [ - [ - "1065.366451276604266", - "0.0", - "715.3187116996945178" - ], - [ - "0.0", - "1068.642040947899886", - "509.4612905764302582" - ], - [ - "0.0", - "0.0", - "1.0" - ] - ], - "description": "Camera 2 calibration matrix for 3.6mm lens", - "category": "calibration", - "ui_name": "Camera 2 3.6mm Lens Matrix" - }, - "gs_config.cameras.3_6Lens_kCamera2DistortionVector": { - "type": "array", - "default": [ - "-0.4854175692996230418", - "0.2637456313607589586", - "0.002679639341176350345", - "-0.001576282874523095", - "0.0" - ], - "description": "Camera 2 distortion for 3.6mm lens", - "category": "calibration", - "ui_name": "Camera 2 3.6mm Lens Distortion" - }, "gs_config.cameras.kCamera1Angles": { "type": "array", "default": [ @@ -4060,13 +3813,6 @@ "description": "Test file for auto calibration", "category": "calibration", "ui_name": "Test Auto Cal File" - }, - "gs_config.ball_identification.kStrobedBallsAltPreHoughBlurSize HAS TO BE ODD": { - "type": "integer", - "default": 0, - "description": "Pre-Hough blur size for ALT algorithm (must be odd)", - "category": "ball_identification", - "ui_name": "ALT Pre-Hough Blur Size" } }, "categoryList": [ From 2693edd131abb0dc423a508fd7d70788ed8f8cc0 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 9 Aug 2026 00:07:45 -0400 Subject: [PATCH 084/156] drop the unused ConfigTestHelper --- Software/web-server/tests/utils/__init__.py | 3 +- .../web-server/tests/utils/test_helpers.py | 71 ------------------- 2 files changed, 1 insertion(+), 73 deletions(-) diff --git a/Software/web-server/tests/utils/__init__.py b/Software/web-server/tests/utils/__init__.py index 53925a6f..76641f48 100644 --- a/Software/web-server/tests/utils/__init__.py +++ b/Software/web-server/tests/utils/__init__.py @@ -6,12 +6,11 @@ """ from .mock_factories import MockConfigManagerFactory, MockProcessManagerFactory -from .test_helpers import ShotDataHelper, ConfigTestHelper, ProcessTestHelper +from .test_helpers import ShotDataHelper, ProcessTestHelper __all__ = [ "MockConfigManagerFactory", "MockProcessManagerFactory", "ShotDataHelper", - "ConfigTestHelper", "ProcessTestHelper", ] diff --git a/Software/web-server/tests/utils/test_helpers.py b/Software/web-server/tests/utils/test_helpers.py index a092b311..63c0921c 100644 --- a/Software/web-server/tests/utils/test_helpers.py +++ b/Software/web-server/tests/utils/test_helpers.py @@ -7,9 +7,6 @@ import random from typing import Dict, Any, List, Optional -from pathlib import Path -import tempfile -import json class ShotDataHelper: @@ -83,74 +80,6 @@ def validate_shot_data(shot_data: Dict[str, Any]) -> bool: return True -class ConfigTestHelper: - """Helper class for configuration-related test operations.""" - - @staticmethod - def create_temp_config_dir() -> Path: - """Create a temporary directory with basic config structure.""" - temp_dir = Path(tempfile.mkdtemp()) - - (temp_dir / ".pitrac" / "config").mkdir(parents=True) - (temp_dir / "LM_Shares" / "Images").mkdir(parents=True) - (temp_dir / "LM_Shares" / "WebShare").mkdir(parents=True) - - return temp_dir - - @staticmethod - def create_config_metadata() -> Dict[str, Any]: - """Create realistic configuration metadata for testing.""" - return { - "settings": { - "gs_config.cameras.kCamera1Gain": { - "type": "number", - "default": 1.0, - "min": 1.0, - "max": 16.0, - "category": "Cameras", - "subcategory": "basic", - }, - "gs_config.cameras.kCamera2Gain": { - "type": "number", - "default": 4.0, - "min": 1.0, - "max": 16.0, - "category": "Cameras", - "subcategory": "basic", - }, - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort": { - "type": "number", - "default": 921, - "min": 1, - "max": 65535, - "category": "Simulators", - "subcategory": "basic", - }, - }, - "validationRules": { - "gain": {"type": "range", "min": 1.0, "max": 16.0, "errorMessage": "Gain must be between 1.0 and 16.0"}, - "port": {"type": "range", "min": 1, "max": 65535, "errorMessage": "Port must be between 1 and 65535"}, - }, - "categoryList": ["Cameras", "Simulators"], - } - - @staticmethod - def create_user_settings(overrides: Optional[Dict] = None) -> Dict[str, Any]: - """Create user settings with optional overrides.""" - default_settings = {"gs_config": {"cameras": {"kCamera1Gain": 2.0}}} - - if overrides: - default_settings.update(overrides) - - return default_settings - - @staticmethod - def write_config_file(path: Path, config: Dict[str, Any]) -> None: - """Write configuration data to a JSON file.""" - with open(path, "w") as f: - json.dump(config, f, indent=2) - - class ProcessTestHelper: """Helper class for process management testing.""" From fd82aed33c59f7d804f2bdd2ab51568cd4e5f4bc Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 10 Aug 2026 20:29:32 -0400 Subject: [PATCH 085/156] drop the config reads whose c++ variables nothing uses --- .../ImageProcessing/ball_image_proc.cpp | 13 --- .../ImageProcessing/ball_image_proc.h | 5 - .../ImageProcessing/gs_camera.cpp | 8 -- .../LMSourceCode/ImageProcessing/gs_camera.h | 4 - .../ImageProcessing/gs_club_data.cpp | 2 - .../ImageProcessing/gs_club_data.h | 1 - .../ImageProcessing/gs_config.cpp | 2 - .../ImageProcessing/libcamera_interface.cpp | 3 - .../ImageProcessing/libcamera_interface.h | 2 - Software/web-server/configurations.json | 99 ------------------- 10 files changed, 139 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp b/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp index cbf7eca0..86efa655 100644 --- a/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp +++ b/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp @@ -148,8 +148,6 @@ namespace golf_sim { int BallImageProc::kExternallyStrobedEnvPreHoughBlurSize = 11; int BallImageProc::kExternallyStrobedEnvPreCannyBlurSize = 3; double BallImageProc::kExternallyStrobedEnvHoughDpParam1 = 1.0; - int BallImageProc::kExternallyStrobedEnvNarrowingPreCannyBlurSize = 3; - int BallImageProc::kExternallyStrobedEnvNarrowingPreHoughBlurSize = 9; int BallImageProc::kExternallyStrobedEnvMinimumSearchRadius = 60; int BallImageProc::kExternallyStrobedEnvMaximumSearchRadius = 80; @@ -186,9 +184,6 @@ namespace golf_sim { double BallImageProc::kBestCircleParam2 = 35.; double BallImageProc::kBestCircleHoughDpParam1 = 1.5; - double BallImageProc::kExternallyStrobedBestCircleCannyLower = 55; - double BallImageProc::kExternallyStrobedBestCircleCannyUpper = 110; - int BallImageProc::kExternallyStrobedBestCirclePreCannyBlurSize = 5; int BallImageProc::kExternallyStrobedBestCirclePreHoughBlurSize = 13; double BallImageProc::kExternallyStrobedBestCircleParam1 = 120.; double BallImageProc::kExternallyStrobedBestCircleParam2 = 35.; @@ -315,8 +310,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvStartingParam2", kExternallyStrobedEnvStartingParam2); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingParam2", kExternallyStrobedEnvNarrowingParam2); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingDpParam", kExternallyStrobedEnvNarrowingDpParam); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingPreCannyBlurSize", kExternallyStrobedEnvNarrowingPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingPreHoughBlurSize", kExternallyStrobedEnvNarrowingPreHoughBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvParam2Increment", kExternallyStrobedEnvParam2Increment); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinHoughReturnCircles", kExternallyStrobedEnvMinHoughReturnCircles); @@ -324,9 +317,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize", kExternallyStrobedEnvPreHoughBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleCannyLower", kExternallyStrobedBestCircleCannyLower); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleCannyUpper", kExternallyStrobedBestCircleCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCirclePreCannyBlurSize", kExternallyStrobedBestCirclePreCannyBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCirclePreHoughBlurSize", kExternallyStrobedBestCirclePreHoughBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam1", kExternallyStrobedBestCircleParam1); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam2", kExternallyStrobedBestCircleParam2); @@ -1714,9 +1704,6 @@ namespace golf_sim { cv::GaussianBlur(cannyOutput_for_balls, finalChoiceSubImg, cv::Size(kBestCirclePreHoughBlurSize, kBestCirclePreHoughBlurSize), 0); // Nominal is 7x7 } else { - // cv::GaussianBlur(finalChoiceSubImg, finalChoiceSubImg, cv::Size(kExternallyStrobedBestCirclePreCannyBlurSize, kExternallyStrobedBestCirclePreCannyBlurSize), 0); - - // cv::Canny(finalChoiceSubImg, cannyOutput_for_balls, kExternallyStrobedBestCircleCannyLower, kExternallyStrobedBestCircleCannyUpper); cannyOutput_for_balls = finalChoiceSubImg.clone(); LoggingTools::DebugShowImage("Best Circle (externally-strobed)" + std::to_string(expandedRadiusForHough) + " cannyOutput for best ball", cannyOutput_for_balls); diff --git a/Software/LMSourceCode/ImageProcessing/ball_image_proc.h b/Software/LMSourceCode/ImageProcessing/ball_image_proc.h index c420235b..71c396d7 100644 --- a/Software/LMSourceCode/ImageProcessing/ball_image_proc.h +++ b/Software/LMSourceCode/ImageProcessing/ball_image_proc.h @@ -140,8 +140,6 @@ class BallImageProc static int kExternallyStrobedEnvMaximumSearchRadius; static double kStrobedNarrowingRadiiDpParam; static double kStrobedNarrowingRadiiParam2; - static int kExternallyStrobedEnvNarrowingPreCannyBlurSize; - static int kExternallyStrobedEnvNarrowingPreHoughBlurSize; static bool kExternallyStrobedUseCLAHEProcessing; static int kExternallyStrobedCLAHEClipLimit; @@ -173,9 +171,6 @@ class BallImageProc static double kBestCircleParam2; static double kBestCircleHoughDpParam1; - static double kExternallyStrobedBestCircleCannyLower; - static double kExternallyStrobedBestCircleCannyUpper; - static int kExternallyStrobedBestCirclePreCannyBlurSize; static int kExternallyStrobedBestCirclePreHoughBlurSize; static double kExternallyStrobedBestCircleParam1; static double kExternallyStrobedBestCircleParam2; diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp index a59eea02..c8623543 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp @@ -104,10 +104,8 @@ namespace golf_sim { int GolfSimCamera::kExternallyStrobedEnvPreHoughBlurSize = 13; // 6 for External Strobe, 9 for normal int GolfSimCamera::kExternallyStrobedEnvPreCannyBlurSize = 3; - int GolfSimCamera::kExternallyStrobedEnvHoughLineIntersections = 235; int GolfSimCamera::kExternallyStrobedEnvLinesAngleLower = 140; int GolfSimCamera::kExternallyStrobedEnvLinesAngleUpper = 180; - int GolfSimCamera::kExternallyStrobedEnvMaximumHoughLineGap = 7; int GolfSimCamera::kExternallyStrobedEnvMinimumHoughLineLength = 23; bool GolfSimCamera::kPlacedBallUseLargestBall = true; @@ -121,8 +119,6 @@ namespace golf_sim { CameraHardware::CameraOrientation GolfSimCamera::kSystemSlot1CameraOrientation = CameraHardware::CameraOrientation::kUpsideUp; CameraHardware::CameraOrientation GolfSimCamera::kSystemSlot2CameraOrientation = CameraHardware::CameraOrientation::kUpsideUp; - float GolfSimCamera::kHLAOffsetAngleDegrees = 0.0; - float GolfSimCamera::kVLAOffsetAngleDegrees = 0.0; bool GolfSimCamera::kUseOnlyHighQualityBallImagesForHLA = true; @@ -204,16 +200,12 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCannyUpper", kExternallyStrobedEnvCannyUpper); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize", kExternallyStrobedEnvPreHoughBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvHoughLineIntersections", kExternallyStrobedEnvHoughLineIntersections); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvLinesAngleLower", kExternallyStrobedEnvLinesAngleLower); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvLinesAngleUpper", kExternallyStrobedEnvLinesAngleUpper); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaximumHoughLineGap", kExternallyStrobedEnvMaximumHoughLineGap); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinimumHoughLineLength", kExternallyStrobedEnvMinimumHoughLineLength); GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallUseLargestBall", kPlacedBallUseLargestBall); - GolfSimConfiguration::SetConstant("gs_config.cameras.kHLAOffsetAngleDegrees", kHLAOffsetAngleDegrees); - GolfSimConfiguration::SetConstant("gs_config.cameras.kVLAOffsetAngleDegrees", kVLAOffsetAngleDegrees); } GolfSimCamera::~GolfSimCamera() { diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.h b/Software/LMSourceCode/ImageProcessing/gs_camera.h index 0fb7ec2b..46b99c3f 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.h +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.h @@ -172,10 +172,8 @@ namespace golf_sim { static int kExternallyStrobedEnvCannyUpper; static int kExternallyStrobedEnvPreHoughBlurSize; static int kExternallyStrobedEnvPreCannyBlurSize; - static int kExternallyStrobedEnvHoughLineIntersections; static int kExternallyStrobedEnvLinesAngleLower; static int kExternallyStrobedEnvLinesAngleUpper; - static int kExternallyStrobedEnvMaximumHoughLineGap; static int kExternallyStrobedEnvMinimumHoughLineLength; @@ -196,8 +194,6 @@ namespace golf_sim { // The following angles are used to adjust the final HLA and VLA to account for // problems such as the auto-calibration rig being slightly off. // See the HLA and VLA calculations in gs_camera.cpp for more details on the meaning of negative versus positive angles - static float kHLAOffsetAngleDegrees; - static float kVLAOffsetAngleDegrees; static bool kUseOnlyHighQualityBallImagesForHLA; diff --git a/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp b/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp index dcc56a31..0e2c1442 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp @@ -29,7 +29,6 @@ namespace golf_sim { uint GolfSimClubData::kClubImageHeightPixels = 150; // The fully-qualified output directory - std::string GolfSimClubData::kClubImageOutputDir; uint GolfSimClubData::kNumberFramesToSaveBeforeHit = 4; uint GolfSimClubData::kNumberFramesToSaveAfterHit = 4; @@ -44,7 +43,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.club_data.kEnableClubImages", kGatherClubData); if (kGatherClubData) { - GolfSimConfiguration::SetConstant("gs_config.club_data.kEnableClubImages", kClubImageOutputDir); GolfSimConfiguration::SetConstant("gs_config.club_data.kNumberFramesToSaveBeforeHit", kNumberFramesToSaveBeforeHit); GolfSimConfiguration::SetConstant("gs_config.club_data.kNumberFramesToSaveAfterHit", kNumberFramesToSaveAfterHit); GolfSimConfiguration::SetConstant("gs_config.club_data.kClubImageWidthPixels", kClubImageWidthPixels); diff --git a/Software/LMSourceCode/ImageProcessing/gs_club_data.h b/Software/LMSourceCode/ImageProcessing/gs_club_data.h index 688665bf..abc0655d 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_club_data.h +++ b/Software/LMSourceCode/ImageProcessing/gs_club_data.h @@ -40,7 +40,6 @@ namespace golf_sim { static uint kClubImageHeightPixels; // The fully-qualified output directory - static std::string kClubImageOutputDir; static uint kNumberFramesToSaveBeforeHit; static uint kNumberFramesToSaveAfterHit; diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index 0fb2bed0..25eb3bab 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -237,13 +237,11 @@ bool GolfSimConfiguration::ReadValues() { SetConstant("gs_config.cameras.kCamera2CalibrateOrLocationGain", LibCameraInterface::kCamera2CalibrateOrLocationGain); SetConstant("gs_config.cameras.kCamera2ComparisonGain", LibCameraInterface::kCamera2ComparisonGain); - SetConstant("gs_config.testing.kCamera2StrobedEnvironmentGain", LibCameraInterface::kCamera2StrobedEnvironmentGain); SetConstant("gs_config.cameras.kCamera2Contrast", LibCameraInterface::kCamera2Contrast); SetConstant("gs_config.cameras.kCamera2PuttingGain", LibCameraInterface::kCamera2PuttingGain); SetConstant("gs_config.cameras.kCamera2PuttingContrast", LibCameraInterface::kCamera2PuttingContrast); SetConstant("gs_config.cameras.kCamera1StillShutterTimeuS", LibCameraInterface::kCamera1StillShutterTimeuS); SetConstant("gs_config.cameras.kCamera2StillShutterTimeuS", LibCameraInterface::kCamera2StillShutterTimeuS); - SetConstant("gs_config.cameras.kCameraMotionDetectSettings", LibCameraInterface::kCameraMotionDetectSettings); // The web server share directory isn't really a value we want to use from the .json configuration // file anymore, but for now, let's allow it as a fall-back to the command line diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp index cc500857..5eb80c90 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp +++ b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp @@ -60,12 +60,10 @@ namespace golf_sim { double LibCameraInterface::kCamera2Gain = 6.0; double LibCameraInterface::kCamera2Saturation = 1.0; double LibCameraInterface::kCamera2ComparisonGain = 0.8; - double LibCameraInterface::kCamera2StrobedEnvironmentGain = 0.8; double LibCameraInterface::kCamera2Contrast = 1.0; double LibCameraInterface::kCamera2CalibrateOrLocationGain = 1.0; double LibCameraInterface::kCamera2PuttingGain = 4.0; double LibCameraInterface::kCamera2PuttingContrast = 1.0; - std::string LibCameraInterface::kCameraMotionDetectSettings = "./assets/motion_detect.json"; long LibCameraInterface::kCamera1StillShutterTimeuS = 15000; long LibCameraInterface::kCamera2StillShutterTimeuS = 15000; @@ -795,7 +793,6 @@ bool ConfigureLibCameraOptions(const GolfSimCamera& camera, RPiCamEncoder& app, // TBD - We are switching away from having the post_process_file trigger the // dynamic loading of the motion_detection module. Instead, hop[efully for speed, // we will use a statically-bound motion_detection module. See ball_watcher.cpp - // options->Set().post_process_file = LibCameraInterface::kCameraMotionDetectSettings; if (cropping_window_size[0] > 0 && cropping_window_size[1] > 0) { options->Set().width = cropping_window_size[0]; diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_interface.h b/Software/LMSourceCode/ImageProcessing/libcamera_interface.h index 17543b76..f0ee91b0 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_interface.h +++ b/Software/LMSourceCode/ImageProcessing/libcamera_interface.h @@ -58,11 +58,9 @@ namespace golf_sim { static double kCamera2Saturation; static double kCamera2ComparisonGain; // 0.0 to TBD?? static double kCamera2CalibrateOrLocationGain; - static double kCamera2StrobedEnvironmentGain; static double kCamera2Contrast; // 0.0 to 32.0 static double kCamera2PuttingGain; // 0.0 to TBD?? static double kCamera2PuttingContrast; // 0.0 to 32.0 - static std::string kCameraMotionDetectSettings; static long kCamera1StillShutterTimeuS; static long kCamera2StillShutterTimeuS; diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 9072b0a0..b25c9091 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -654,24 +654,6 @@ "default": 0.0, "requiresRestart": false }, - "gs_config.cameras.kHLAOffsetAngleDegrees": { - "category": "Cameras", - "subcategory": "advanced", - "displayName": "HLA Offset Angle (degrees)", - "description": "Read but not applied to results. The HLA correction applied to shots is HLA Offset.", - "type": "number", - "default": 0.0, - "requiresRestart": true - }, - "gs_config.cameras.kVLAOffsetAngleDegrees": { - "category": "Cameras", - "subcategory": "advanced", - "displayName": "VLA Offset Angle (degrees)", - "description": "Read but not applied to results. The VLA correction applied to shots is VLA Offset.", - "type": "number", - "default": 0.0, - "requiresRestart": true - }, "gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress": { "category": "Simulators", "subcategory": "basic", @@ -1163,15 +1145,6 @@ "default": 15000, "requiresRestart": true }, - "gs_config.cameras.kCameraMotionDetectSettings": { - "category": "Cameras", - "subcategory": "advanced", - "displayName": "Camera Motion Detect Settings", - "description": "Path to the libcamera motion detect settings file. Not used by the current capture path.", - "type": "string", - "default": "./assets/motion_detect.json", - "requiresRestart": true - }, "gs_config.physical_constants.kBallRadiusMeters": { "category": "System", "subcategory": "advanced", @@ -3124,33 +3097,6 @@ "category": "testing", "ui_name": "Test Image Directory" }, - "gs_config.testing.kCamera2StrobedEnvironmentGain": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Camera 2 Strobed Environment Gain", - "description": "Camera 2 gain in an externally strobed environment", - "type": "number", - "default": 0.8, - "requiresRestart": true - }, - "gs_config.testing.kExternallyStrobedBestCircleCannyLower": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Ext Strobe Best Circle Canny Lower", - "description": "Canny lower threshold for best-circle detection with external strobes", - "type": "number", - "default": 55, - "requiresRestart": true - }, - "gs_config.testing.kExternallyStrobedBestCircleCannyUpper": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Ext Strobe Best Circle Canny Upper", - "description": "Canny upper threshold for best-circle detection with external strobes", - "type": "number", - "default": 110, - "requiresRestart": true - }, "gs_config.testing.kExternallyStrobedBestCircleHoughDpParam1": { "category": "testing", "subcategory": "advanced", @@ -3178,15 +3124,6 @@ "default": 35.0, "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedBestCirclePreCannyBlurSize": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Ext Strobe Best Circle Pre-Canny Blur", - "description": "Pre-Canny blur size for best-circle detection with external strobes", - "type": "integer", - "default": 5, - "requiresRestart": true - }, "gs_config.testing.kExternallyStrobedBestCirclePreHoughBlurSize": { "category": "testing", "subcategory": "advanced", @@ -3322,15 +3259,6 @@ "default": 1.0, "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedEnvHoughLineIntersections": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Ext Strobe Hough Line Intersections", - "description": "Hough line intersection threshold with external strobes", - "type": "integer", - "default": 235, - "requiresRestart": true - }, "gs_config.testing.kExternallyStrobedEnvLinesAngleLower": { "category": "testing", "subcategory": "advanced", @@ -3367,15 +3295,6 @@ "default": 100, "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedEnvMaximumHoughLineGap": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Ext Strobe Max Hough Line Gap", - "description": "Maximum Hough line gap with external strobes", - "type": "integer", - "default": 7, - "requiresRestart": true - }, "gs_config.testing.kExternallyStrobedEnvMaximumSearchRadius": { "category": "testing", "subcategory": "advanced", @@ -3430,24 +3349,6 @@ "default": 0.6, "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedEnvNarrowingPreCannyBlurSize": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Ext Strobe Narrowing Pre-Canny Blur", - "description": "Pre-Canny blur size for the narrowing pass with external strobes", - "type": "integer", - "default": 3, - "requiresRestart": true - }, - "gs_config.testing.kExternallyStrobedEnvNarrowingPreHoughBlurSize": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Ext Strobe Narrowing Pre-Hough Blur", - "description": "Pre-Hough blur size for the narrowing pass with external strobes", - "type": "integer", - "default": 9, - "requiresRestart": true - }, "gs_config.testing.kExternallyStrobedEnvNumber_bits_for_fast_on_pulse_": { "category": "testing", "subcategory": "advanced", From 19cf498e38fc5a38dbb75542a442556fe7329813 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 12 Aug 2026 21:13:09 -0400 Subject: [PATCH 086/156] load GolfSimCamera constants once at startup instead of in every constructor --- Software/LMSourceCode/ImageProcessing/gs_camera.cpp | 9 ++++----- Software/LMSourceCode/ImageProcessing/gs_camera.h | 3 +++ Software/LMSourceCode/ImageProcessing/lm_main.cpp | 3 ++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp index c8623543..20e94190 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp @@ -124,12 +124,9 @@ namespace golf_sim { bool GolfSimCamera::kUseOnlyHighQualityBallImagesForHLA = true; - GolfSimCamera::GolfSimCamera() { - - // TBD - Probably shouldn't be doing all of this in the constructor - + void GolfSimCamera::LoadConfigurationValues() { GS_LOG_TRACE_MSG(trace, "GolfSimCamera reading constants from JSON file."); - // The following constants are only used internal to the GolfSimCamera class, and so can be initialized in the constructor + GolfSimConfiguration::SetConstant("gs_config.logging.kLogIntermediateExposureImagesToFile", kLogIntermediateExposureImagesToFile); GolfSimConfiguration::SetConstant("gs_config.logging.kLogWebserverImagesToFile", kLogWebserverImagesToFile); GolfSimConfiguration::SetConstant("gs_config.logging.kLogDiagnosticImagesToUniqueFiles", kLogDiagnosticImagesToUniqueFiles); @@ -205,7 +202,9 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinimumHoughLineLength", kExternallyStrobedEnvMinimumHoughLineLength); GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallUseLargestBall", kPlacedBallUseLargestBall); + } + GolfSimCamera::GolfSimCamera() { } GolfSimCamera::~GolfSimCamera() { diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.h b/Software/LMSourceCode/ImageProcessing/gs_camera.h index 46b99c3f..5101dd4c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.h +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.h @@ -204,6 +204,9 @@ namespace golf_sim { ~GolfSimCamera(); + // Call once, after GolfSimConfiguration::Initialize + static void LoadConfigurationValues(); + // One of the main workhorses of the system. It determines a ball in and // image by using various circle-identification algorithms and other processing. // diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 972fd33e..67982be4 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1537,8 +1537,9 @@ int main(int argc, char *argv[]) // TBD - consider if there is a better place for this? GolfSimGlobals::golf_sim_running_ = true; - // Load BallImageProc configuration values after JSON config is loaded + // Load class configuration values after JSON config is loaded BallImageProc::LoadConfigurationValues(); + GolfSimCamera::LoadConfigurationValues(); // If we have a version 3 Connector Board, then we want to ensure // that it has been properly calibrated before we let the system From 8c17723f9bb391ce6ee0564eba5cb2c9a94ef92d Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 13 Aug 2026 10:29:14 -0400 Subject: [PATCH 087/156] fix the strobe cleanup defaults and test the runtime-built keys --- .../ImageProcessing/gs_camera.cpp | 14 ++--- .../LMSourceCode/ImageProcessing/gs_camera.h | 6 +- .../ImageProcessing/gs_club_data.cpp | 2 - .../ImageProcessing/gs_club_data.h | 2 - Software/web-server/configurations.json | 43 ++++++++++--- Software/web-server/tests/test_config_keys.py | 62 +++++++++++-------- 6 files changed, 78 insertions(+), 51 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp index 20e94190..b81a131f 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp @@ -101,7 +101,7 @@ namespace golf_sim { int GolfSimCamera::kExternallyStrobedEnvFilterHsvUpperV = 255; int GolfSimCamera::kExternallyStrobedEnvCannyLower = 156; int GolfSimCamera::kExternallyStrobedEnvCannyUpper = 337; - int GolfSimCamera::kExternallyStrobedEnvPreHoughBlurSize = 13; // 6 for External Strobe, 9 for normal + int GolfSimCamera::kExternallyStrobedEnvCleanupPreHoughBlurSize = 13; // 6 for External Strobe, 9 for normal int GolfSimCamera::kExternallyStrobedEnvPreCannyBlurSize = 3; int GolfSimCamera::kExternallyStrobedEnvLinesAngleLower = 140; @@ -119,8 +119,6 @@ namespace golf_sim { CameraHardware::CameraOrientation GolfSimCamera::kSystemSlot1CameraOrientation = CameraHardware::CameraOrientation::kUpsideUp; CameraHardware::CameraOrientation GolfSimCamera::kSystemSlot2CameraOrientation = CameraHardware::CameraOrientation::kUpsideUp; - - bool GolfSimCamera::kUseOnlyHighQualityBallImagesForHLA = true; @@ -195,7 +193,7 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvFilterHsvUpperV", kExternallyStrobedEnvFilterHsvUpperV); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCannyLower", kExternallyStrobedEnvCannyLower); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCannyUpper", kExternallyStrobedEnvCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize", kExternallyStrobedEnvPreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCleanupPreHoughBlurSize", kExternallyStrobedEnvCleanupPreHoughBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvLinesAngleLower", kExternallyStrobedEnvLinesAngleLower); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvLinesAngleUpper", kExternallyStrobedEnvLinesAngleUpper); @@ -3557,11 +3555,11 @@ namespace golf_sim { LoggingTools::DebugShowImage("Initial cannyOutput", cannyOutput_for_balls); - if (kExternallyStrobedEnvPreHoughBlurSize > 0) { - if (kExternallyStrobedEnvPreHoughBlurSize % 2 != 1) { - kExternallyStrobedEnvPreHoughBlurSize++; + if (kExternallyStrobedEnvCleanupPreHoughBlurSize > 0) { + if (kExternallyStrobedEnvCleanupPreHoughBlurSize % 2 != 1) { + kExternallyStrobedEnvCleanupPreHoughBlurSize++; } - cv::GaussianBlur(cannyOutput_for_balls, cannyOutput_for_balls, cv::Size(kExternallyStrobedEnvPreHoughBlurSize, kExternallyStrobedEnvPreHoughBlurSize), 0); + cv::GaussianBlur(cannyOutput_for_balls, cannyOutput_for_balls, cv::Size(kExternallyStrobedEnvCleanupPreHoughBlurSize, kExternallyStrobedEnvCleanupPreHoughBlurSize), 0); } LoggingTools::DebugShowImage("Post-Blur cannyOutput", cannyOutput_for_balls); diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.h b/Software/LMSourceCode/ImageProcessing/gs_camera.h index 5101dd4c..318167ed 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.h +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.h @@ -170,7 +170,7 @@ namespace golf_sim { static int kExternallyStrobedEnvFilterHsvUpperV; static int kExternallyStrobedEnvCannyLower; static int kExternallyStrobedEnvCannyUpper; - static int kExternallyStrobedEnvPreHoughBlurSize; + static int kExternallyStrobedEnvCleanupPreHoughBlurSize; static int kExternallyStrobedEnvPreCannyBlurSize; static int kExternallyStrobedEnvLinesAngleLower; static int kExternallyStrobedEnvLinesAngleUpper; @@ -191,10 +191,6 @@ namespace golf_sim { static CameraHardware::CameraOrientation kSystemSlot1CameraOrientation; static CameraHardware::CameraOrientation kSystemSlot2CameraOrientation; - // The following angles are used to adjust the final HLA and VLA to account for - // problems such as the auto-calibration rig being slightly off. - // See the HLA and VLA calculations in gs_camera.cpp for more details on the meaning of negative versus positive angles - static bool kUseOnlyHighQualityBallImagesForHLA; // Refers to the camera_hardware device object associated with this higher-level camera object diff --git a/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp b/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp index 0e2c1442..6ecc83d2 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp @@ -28,8 +28,6 @@ namespace golf_sim { uint GolfSimClubData::kClubImageWidthPixels = 200; uint GolfSimClubData::kClubImageHeightPixels = 150; - // The fully-qualified output directory - uint GolfSimClubData::kNumberFramesToSaveBeforeHit = 4; uint GolfSimClubData::kNumberFramesToSaveAfterHit = 4; diff --git a/Software/LMSourceCode/ImageProcessing/gs_club_data.h b/Software/LMSourceCode/ImageProcessing/gs_club_data.h index abc0655d..4c2d782c 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_club_data.h +++ b/Software/LMSourceCode/ImageProcessing/gs_club_data.h @@ -39,8 +39,6 @@ namespace golf_sim { static uint kClubImageWidthPixels; static uint kClubImageHeightPixels; - // The fully-qualified output directory - static uint kNumberFramesToSaveBeforeHit; static uint kNumberFramesToSaveAfterHit; diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index b25c9091..435a356f 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -786,6 +786,24 @@ "default": 87, "requiresRestart": false }, + "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera1": { + "category": "Cameras", + "subcategory": "advanced", + "displayName": "Camera 1 Expected Ball Radius at 40cm (px)", + "description": "Expected ball radius in pixels at 40 cm for camera 1. A value below 1 uses the camera model's built-in value.", + "type": "integer", + "default": -1, + "requiresRestart": true + }, + "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera2": { + "category": "Cameras", + "subcategory": "advanced", + "displayName": "Camera 2 Expected Ball Radius at 40cm (px)", + "description": "Expected ball radius in pixels at 40 cm for camera 2. A value below 1 uses the camera model's built-in value.", + "type": "integer", + "default": -1, + "requiresRestart": true + }, "gs_config.strobing.kStandardBallSpeedSlowdownPercentage": { "category": "Strobing", "subcategory": "basic", @@ -3163,19 +3181,28 @@ "gs_config.testing.kExternallyStrobedEnvCannyLower": { "category": "testing", "subcategory": "advanced", - "displayName": "Ext Strobe Canny Lower", - "description": "Canny lower threshold with external strobes", - "type": "number", - "default": 35, + "displayName": "Ext Strobe Cleanup Canny Lower", + "description": "Canny lower threshold for external strobe artifact cleanup", + "type": "integer", + "default": 156, "requiresRestart": true }, "gs_config.testing.kExternallyStrobedEnvCannyUpper": { "category": "testing", "subcategory": "advanced", - "displayName": "Ext Strobe Canny Upper", - "description": "Canny upper threshold with external strobes", - "type": "number", - "default": 80, + "displayName": "Ext Strobe Cleanup Canny Upper", + "description": "Canny upper threshold for external strobe artifact cleanup", + "type": "integer", + "default": 337, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvCleanupPreHoughBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Cleanup Pre-Hough Blur", + "description": "Pre-Hough blur size for external strobe artifact cleanup", + "type": "integer", + "default": 13, "requiresRestart": true }, "gs_config.testing.kExternallyStrobedEnvCurrentParam1": { diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py index f195dd21..08926806 100644 --- a/Software/web-server/tests/test_config_keys.py +++ b/Software/web-server/tests/test_config_keys.py @@ -8,38 +8,48 @@ CPP_DIR = HERE / "../../LMSourceCode/ImageProcessing" SCHEMA = HERE.parent / "configurations.json" -# Tags the C++ builds at runtime (gs_config.cpp GetConfigString/apply_enum, camera_hardware.cpp) +# Keys camera_hardware.cpp builds at runtime, {n} = camera 1 and 2, with the source text that builds each DYNAMIC_KEYS = { - "cameras.slot1.type", - "cameras.slot1.lens", - "cameras.slot1.orientation", - "cameras.slot2.type", - "cameras.slot2.lens", - "cameras.slot2.orientation", - "logging.level", - "gs_config.player.kGolferOrientation", - "gs_config.logging.kArtifactSaveLevel", - "gs_config.cameras.kCamera1FocalLength", - "gs_config.cameras.kCamera2FocalLength", - "gs_config.cameras.kCamera1Angles", - "gs_config.cameras.kCamera2Angles", - "gs_config.cameras.kCamera1CalibrationMatrix", - "gs_config.cameras.kCamera2CalibrationMatrix", - "gs_config.cameras.kCamera1DistortionVector", - "gs_config.cameras.kCamera2DistortionVector", - "cameras.kSlot1CustomLensFocalLength", - "cameras.kSlot2CustomLensFocalLength", + "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera{n}": [ + '"gs_config.cameras." + ball_radius_pixels_at_40cm_name', + '"kExpectedBallRadiusPixelsAt40cmCamera" + std::string(camera_number == GsCameraNumber::kGsCamera1 ? "1" : "2")', + ], + "gs_config.cameras.kCamera{n}CalibrationMatrix": [ + '"gs_config.cameras." + calibration_element_name', + '"kCamera" + std::to_string((int)camera_number_) + "CalibrationMatrix"', + ], + "gs_config.cameras.kCamera{n}DistortionVector": [ + '"gs_config.cameras." + distortion_element_name', + '"kCamera" + std::to_string((int)camera_number_) + "DistortionVector"', + ], + "cameras.kSlot{n}CustomLensFocalLength": [ + '"cameras.kSlot" + std::to_string(camera_number_) + "CustomLensFocalLength"', + ], + "gs_config.cameras.kCamera{n}FocalLength": [ + '"gs_config.cameras.kCamera" + std::to_string(camera_number_) + "FocalLength"', + ], + "gs_config.cameras.kCamera{n}Angles": [ + '"gs_config.cameras.kCamera" + std::to_string(camera_number_) + "Angles"', + ], } +# The calls above plus GetConfigString forwarding its tag_name +NON_LITERAL_SET_CONSTANT_CALLS = 6 + def test_every_key_the_cpp_reads_is_in_the_schema(): - literal_keys = { - key - for path in CPP_DIR.glob("*.cpp") - for key in re.findall(r'SetConstant\(\s*"([^"]+)"\s*,', path.read_text(errors="replace")) - } + source = "\n".join(p.read_text(errors="replace") for p in sorted(CPP_DIR.glob("*.cpp"))) + + literal_keys = set(re.findall(r'(?:SetConstant|GetConfigString|apply_enum)\(\s*"([^"]+)"\s*[,)]', source)) assert len(literal_keys) > 200 + non_literal_calls = re.findall(r'SetConstant\((?!\s*const\b)(?!\s*"[^"]+"\s*,)', source) + assert len(non_literal_calls) == NON_LITERAL_SET_CONSTANT_CALLS, "update DYNAMIC_KEYS for the new runtime-built key" + + stale = [f for fragments in DYNAMIC_KEYS.values() for f in fragments if f not in source] + assert stale == [] + dynamic_keys = {template.format(n=n) for template in DYNAMIC_KEYS for n in (1, 2)} + settings = json.loads(SCHEMA.read_text())["settings"] - missing = sorted((literal_keys | DYNAMIC_KEYS) - settings.keys()) + missing = sorted((literal_keys | dynamic_keys) - settings.keys()) assert missing == [] From 41166b4959805be959fce79d310a6b597fe8cb32 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 13 Aug 2026 10:53:20 -0400 Subject: [PATCH 088/156] sync c++ initializers to the schema defaults --- .../ImageProcessing/ball_image_proc.cpp | 100 +++++++++--------- .../ImageProcessing/gs_calibration.cpp | 4 +- .../ImageProcessing/gs_camera.cpp | 70 ++++++------ .../ImageProcessing/gs_club_data.cpp | 10 +- .../LMSourceCode/ImageProcessing/gs_fsm.cpp | 2 +- .../ImageProcessing/libcamera_interface.cpp | 12 +-- .../ImageProcessing/pulse_strobe.cpp | 10 +- Software/web-server/tests/test_config_keys.py | 59 ++++++++++- 8 files changed, 162 insertions(+), 105 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp b/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp index 86efa655..0959d82e 100644 --- a/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp +++ b/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp @@ -71,39 +71,39 @@ namespace golf_sim { static const double kEllipseColorMaskWideningAmount = 35; static const bool kSerializeOpsForDebug = false; - int BallImageProc::kCoarseXRotationDegreesIncrement = 6; - int BallImageProc::kCoarseXRotationDegreesStart = -42; - int BallImageProc::kCoarseXRotationDegreesEnd = 42; + int BallImageProc::kCoarseXRotationDegreesIncrement = 4; + int BallImageProc::kCoarseXRotationDegreesStart = -36; + int BallImageProc::kCoarseXRotationDegreesEnd = 36; int BallImageProc::kCoarseYRotationDegreesIncrement = 5; - int BallImageProc::kCoarseYRotationDegreesStart = -30; - int BallImageProc::kCoarseYRotationDegreesEnd = 30; - int BallImageProc::kCoarseZRotationDegreesIncrement = 6; - int BallImageProc::kCoarseZRotationDegreesStart = -50; - int BallImageProc::kCoarseZRotationDegreesEnd = 60; + int BallImageProc::kCoarseYRotationDegreesStart = -15; + int BallImageProc::kCoarseYRotationDegreesEnd = 15; + int BallImageProc::kCoarseZRotationDegreesIncrement = 4; + int BallImageProc::kCoarseZRotationDegreesStart = -10; + int BallImageProc::kCoarseZRotationDegreesEnd = 110; int BallImageProc::kCoarseSearchResolution = 90; double BallImageProc::kPlacedBallCannyLower; double BallImageProc::kPlacedBallCannyUpper; - double BallImageProc::kPlacedBallStartingParam2 = 40; - double BallImageProc::kPlacedBallMinParam2 = 30; - double BallImageProc::kPlacedBallMaxParam2 = 60; - double BallImageProc::kPlacedBallCurrentParam1 = 120.0; - double BallImageProc::kPlacedBallParam2Increment = 4; + double BallImageProc::kPlacedBallStartingParam2 = 0.9; + double BallImageProc::kPlacedBallMinParam2 = 0.8; + double BallImageProc::kPlacedBallMaxParam2 = 1.0; + double BallImageProc::kPlacedBallCurrentParam1 = 130.0; + double BallImageProc::kPlacedBallParam2Increment = 0.03; int BallImageProc::kPlacedMinHoughReturnCircles = 1; int BallImageProc::kPlacedMaxHoughReturnCircles = 4; - double BallImageProc::kStrobedBallsCannyLower = 50; - double BallImageProc::kStrobedBallsCannyUpper = 110; + double BallImageProc::kStrobedBallsCannyLower = 33; + double BallImageProc::kStrobedBallsCannyUpper = 90; - int BallImageProc::kStrobedBallsMaxHoughReturnCircles = 12; - int BallImageProc::kStrobedBallsMinHoughReturnCircles = 1; + int BallImageProc::kStrobedBallsMaxHoughReturnCircles = 20; + int BallImageProc::kStrobedBallsMinHoughReturnCircles = 6; - int BallImageProc::kStrobedBallsPreCannyBlurSize = 5; + int BallImageProc::kStrobedBallsPreCannyBlurSize = 3; int BallImageProc::kStrobedBallsPreHoughBlurSize = 13; - double BallImageProc::kStrobedBallsStartingParam2 = 40; - double BallImageProc::kStrobedBallsMinParam2 = 30; - double BallImageProc::kStrobedBallsMaxParam2 = 60; + double BallImageProc::kStrobedBallsStartingParam2 = 60; + double BallImageProc::kStrobedBallsMinParam2 = 18; + double BallImageProc::kStrobedBallsMaxParam2 = 140; double BallImageProc::kStrobedBallsCurrentParam1 = 120.0; double BallImageProc::kStrobedBallsHoughDpParam1 = 1.5; double BallImageProc::kStrobedBallsParam2Increment = 4; @@ -111,26 +111,26 @@ namespace golf_sim { bool BallImageProc::kStrobedBallsUseAltHoughAlgorithm = true; double BallImageProc::kStrobedBallsAltCannyLower = 35; double BallImageProc::kStrobedBallsAltCannyUpper = 70; - int BallImageProc::kStrobedBallsAltPreCannyBlurSize = 11; - int BallImageProc::kStrobedBallsAltPreHoughBlurSize = 16; - double BallImageProc::kStrobedBallsAltStartingParam2 = 0.95; - double BallImageProc::kStrobedBallsAltMinParam2 = 0.6; + int BallImageProc::kStrobedBallsAltPreCannyBlurSize = 9; + int BallImageProc::kStrobedBallsAltPreHoughBlurSize = 15; + double BallImageProc::kStrobedBallsAltStartingParam2 = 0.65; + double BallImageProc::kStrobedBallsAltMinParam2 = 0.5; double BallImageProc::kStrobedBallsAltMaxParam2 = 1.0; double BallImageProc::kStrobedBallsAltCurrentParam1 = 130.0; - double BallImageProc::kStrobedBallsAltHoughDpParam1 = 1.5; + double BallImageProc::kStrobedBallsAltHoughDpParam1 = 0.8; double BallImageProc::kStrobedBallsAltParam2Increment = 0.05; bool BallImageProc::kUseCLAHEProcessing; int BallImageProc::kCLAHEClipLimit; int BallImageProc::kCLAHETilesGridSize; - double BallImageProc::kPuttingBallStartingParam2 = 40; - double BallImageProc::kPuttingBallMinParam2 = 30; - double BallImageProc::kPuttingBallMaxParam2 = 60; - double BallImageProc::kPuttingBallCurrentParam1 = 120.0; - double BallImageProc::kPuttingBallParam2Increment = 4; - int BallImageProc::kPuttingMaxHoughReturnCircles = 12; - int BallImageProc::kPuttingMinHoughReturnCircles = 1; + double BallImageProc::kPuttingBallStartingParam2 = 0.9; + double BallImageProc::kPuttingBallMinParam2 = 0.8; + double BallImageProc::kPuttingBallMaxParam2 = 1.0; + double BallImageProc::kPuttingBallCurrentParam1 = 300.0; + double BallImageProc::kPuttingBallParam2Increment = 0.03; + int BallImageProc::kPuttingMaxHoughReturnCircles = 25; + int BallImageProc::kPuttingMinHoughReturnCircles = 6; double BallImageProc::kPuttingHoughDpParam1 = 1.5; double BallImageProc::kExternallyStrobedEnvCannyLower = 35; @@ -151,22 +151,22 @@ namespace golf_sim { int BallImageProc::kExternallyStrobedEnvMinimumSearchRadius = 60; int BallImageProc::kExternallyStrobedEnvMaximumSearchRadius = 80; - bool BallImageProc::kUseDynamicRadiiAdjustment = true; - int BallImageProc::kNumberRadiiToAverageForDynamicAdjustment = 3; - double BallImageProc::kStrobedNarrowingRadiiMinRatio = 0.8; - double BallImageProc::kStrobedNarrowingRadiiMaxRatio = 1.2; - double BallImageProc::kStrobedNarrowingRadiiDpParam = 1.8; - double BallImageProc::kStrobedNarrowingRadiiParam2 = 100.0; + bool BallImageProc::kUseDynamicRadiiAdjustment = false; + int BallImageProc::kNumberRadiiToAverageForDynamicAdjustment = 2; + double BallImageProc::kStrobedNarrowingRadiiMinRatio = 0.7; + double BallImageProc::kStrobedNarrowingRadiiMaxRatio = 1.6; + double BallImageProc::kStrobedNarrowingRadiiDpParam = 1.5; + double BallImageProc::kStrobedNarrowingRadiiParam2 = 0.8; double BallImageProc::kPlacedNarrowingRadiiMinRatio = 0.9; double BallImageProc::kPlacedNarrowingRadiiMaxRatio = 1.1; - double BallImageProc::kPlacedNarrowingStartingParam2 = 80.0; - double BallImageProc::kPlacedNarrowingRadiiDpParam = 2.0; + double BallImageProc::kPlacedNarrowingStartingParam2 = 0.9; + double BallImageProc::kPlacedNarrowingRadiiDpParam = 1.5; double BallImageProc::kPlacedNarrowingParam1 = 130.0; - int BallImageProc::kPlacedPreCannyBlurSize = 5; - int BallImageProc::kPlacedPreHoughBlurSize = 11; + int BallImageProc::kPlacedPreCannyBlurSize = 11; + int BallImageProc::kPlacedPreHoughBlurSize = 13; int BallImageProc::kPuttingPreHoughBlurSize = 9; @@ -180,8 +180,8 @@ namespace golf_sim { double BallImageProc::kBestCircleCannyUpper = 110; int BallImageProc::kBestCirclePreCannyBlurSize = 5; int BallImageProc::kBestCirclePreHoughBlurSize = 13; - double BallImageProc::kBestCircleParam1 = 120.; - double BallImageProc::kBestCircleParam2 = 35.; + double BallImageProc::kBestCircleParam1 = 300.; + double BallImageProc::kBestCircleParam2 = 0.65; double BallImageProc::kBestCircleHoughDpParam1 = 1.5; int BallImageProc::kExternallyStrobedBestCirclePreHoughBlurSize = 13; @@ -193,11 +193,11 @@ namespace golf_sim { int BallImageProc::kExternallyStrobedCLAHEClipLimit = 6; int BallImageProc::kExternallyStrobedCLAHETilesGridSize = 6; - double BallImageProc::kBestCircleIdentificationMinRadiusRatio = 0.85; - double BallImageProc::kBestCircleIdentificationMaxRadiusRatio = 1.10; + double BallImageProc::kBestCircleIdentificationMinRadiusRatio = 0.9; + double BallImageProc::kBestCircleIdentificationMaxRadiusRatio = 1.2; - int BallImageProc::kGaborMaxWhitePercent = 44; // Nominal 46; - int BallImageProc::kGaborMinWhitePercent = 38; // Nominal 40; + int BallImageProc::kGaborMaxWhitePercent = 45; // Nominal 46; + int BallImageProc::kGaborMinWhitePercent = 39; // Nominal 40; std::string BallImageProc::kSpinDetectionMethod = "ml"; // Model Detection Configuration @@ -206,7 +206,7 @@ namespace golf_sim { #ifdef _WIN32 std::string BallImageProc::kModelPath = "../../Software/LMSourceCode/ml_models/yolo26-ball-detector"; #else - std::string BallImageProc::kModelPath = "../ml_models/yolo26-ball-detector"; + std::string BallImageProc::kModelPath = "/etc/pitrac/models/yolo26-ball-detector"; #endif float BallImageProc::kModelConfidenceThreshold = 0.5f; float BallImageProc::kModelNMSThreshold = 0.4f; diff --git a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp index 01995aaa..3d80f624 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp @@ -55,9 +55,9 @@ namespace golf_sim { // Number of pictures to average when determining focal length. Because the focal length can tend // to bounce around a bit due to small variations in ball detection, averaging multiple pictures can help. - int GolfSimCalibration::kNumberPicturesForFocalLengthAverage = 5; + int GolfSimCalibration::kNumberPicturesForFocalLengthAverage = 6; - int GolfSimCalibration::kNumberOfCalibrationFailuresToTolerate = 2; + int GolfSimCalibration::kNumberOfCalibrationFailuresToTolerate = 4; GolfSimCalibration::GolfSimCalibration() { diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp index b81a131f..5d4f8586 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp @@ -25,15 +25,15 @@ namespace golf_sim { // Constants used by this class bool GolfSimCamera::kLogIntermediateExposureImagesToFile = false; bool GolfSimCamera::kLogWebserverImagesToFile = true; - bool GolfSimCamera::kLogDiagnosticImagesToUniqueFiles = false; + bool GolfSimCamera::kLogDiagnosticImagesToUniqueFiles = true; - int GolfSimCamera::kMaximumOffTrajectoryDistance = 5; + int GolfSimCamera::kMaximumOffTrajectoryDistance = 8; unsigned int GolfSimCamera::kNumberHighQualityBallsToRetain = 2; - double GolfSimCamera::kMaxStrobedBallColorDifferenceRelaxed = 35000.; - double GolfSimCamera::kMaxPuttingBallColorDifferenceRelaxed = 35000.; - double GolfSimCamera::kMaxStrobedBallColorDifferenceStrict = 15000.; - double GolfSimCamera::kBallProximityMarginPercentRelaxed = 50.; - double GolfSimCamera::kBallProximityMarginPercentStrict = 5.; + double GolfSimCamera::kMaxStrobedBallColorDifferenceRelaxed = 70000.; + double GolfSimCamera::kMaxPuttingBallColorDifferenceRelaxed = 40000.; + double GolfSimCamera::kMaxStrobedBallColorDifferenceStrict = 30000.; + double GolfSimCamera::kBallProximityMarginPercentRelaxed = 65.; + double GolfSimCamera::kBallProximityMarginPercentStrict = 15.; // These constants may be used before this class's constructor is called. // For that reason, they are initialized in the GsConfiguration startup @@ -41,55 +41,55 @@ namespace golf_sim { cv::Vec3d GolfSimCamera::kCamera2PositionsFromExpectedBallMeters; cv::Vec3d GolfSimCamera::kCamera2OffsetFromCamera1OriginMeters; - double GolfSimCamera::kColorDifferenceRgbPostMultiplierForDarker = 5.0; - double GolfSimCamera::kColorDifferenceRgbPostMultiplierForLighter = 10.0; + double GolfSimCamera::kColorDifferenceRgbPostMultiplierForDarker = 4.0; + double GolfSimCamera::kColorDifferenceRgbPostMultiplierForLighter = 1.0; double GolfSimCamera::kColorDifferenceStdPostMultiplierForDarker = 3.0; - double GolfSimCamera::kColorDifferenceStdPostMultiplierForLighter = 2.0; + double GolfSimCamera::kColorDifferenceStdPostMultiplierForLighter = 5.0; - double GolfSimCamera::kMaxDistanceFromTrajectory = 20.; + double GolfSimCamera::kMaxDistanceFromTrajectory = 30.; int GolfSimCamera::kClosestBallPairEdgeBackoffPixels = 200; - double GolfSimCamera::kMaxIntermediateBallRadiusChangePercent = 10.0; - double GolfSimCamera::kMaxPuttingIntermediateBallRadiusChangePercent = 10.0; + double GolfSimCamera::kMaxIntermediateBallRadiusChangePercent = 5.0; + double GolfSimCamera::kMaxPuttingIntermediateBallRadiusChangePercent = 8.0; double GolfSimCamera::kMaxOverlappedBallRadiusChangeRatio = 1.3; - double GolfSimCamera::kMaxRadiusDifferencePercentageFromBest = 20; + double GolfSimCamera::kMaxRadiusDifferencePercentageFromBest = 35; bool GolfSimCamera::kUsePreImageSubtraction = false; // Ultimately, this concept was not as helpful as hoped for - double GolfSimCamera::kPreImageWeightingOverall = 1.0; - double GolfSimCamera::kPreImageWeightingBlue = 1.0; - double GolfSimCamera::kPreImageWeightingGreen = 1.0; + double GolfSimCamera::kPreImageWeightingOverall = 0.0; + double GolfSimCamera::kPreImageWeightingBlue = 1.05; + double GolfSimCamera::kPreImageWeightingGreen = 1.2; double GolfSimCamera::kPreImageWeightingRed = 1.0; - float GolfSimCamera::kTeedBallSearchAreaMaskRadiusRatio = 5.0f; + float GolfSimCamera::kTeedBallSearchAreaMaskRadiusRatio = 0.0f; double GolfSimCamera::kCamera1XOffsetForTilt = 0.0; double GolfSimCamera::kCamera1YOffsetForTilt = 0.0; double GolfSimCamera::kCamera2XOffsetForTilt = 0.0; double GolfSimCamera::kCamera2YOffsetForTilt = 0.0; - double GolfSimCamera::kExpectedBallRadiusPixelsAt40cm = 50; - float GolfSimCamera::kMaxMovedBallRadiusRatio = 1.40f; - float GolfSimCamera::kMinMovedBallRadiusRatio = 0.50f; - double GolfSimCamera::kMinRadiusRatio = 0.7; - double GolfSimCamera::kMaxRadiusRatio = 1.2; + double GolfSimCamera::kExpectedBallRadiusPixelsAt40cm = 87; + float GolfSimCamera::kMaxMovedBallRadiusRatio = 1.5f; + float GolfSimCamera::kMinMovedBallRadiusRatio = 0.6f; + double GolfSimCamera::kMinRadiusRatio = 0.8; + double GolfSimCamera::kMaxRadiusRatio = 1.7; // Fixed amount to subtract or add to an expected radius to produce a reasonable range - int GolfSimCamera::kMinRadiusOffset = 10; - int GolfSimCamera::kMaxRadiusOffset = 10; + int GolfSimCamera::kMinRadiusOffset = 20; + int GolfSimCamera::kMaxRadiusOffset = 20; double GolfSimCamera::kUnlikelyAngleMinimumDistancePixels = 40; - double GolfSimCamera::kMaxQualityExposureLaunchAngle = 45.0; + double GolfSimCamera::kMaxQualityExposureLaunchAngle = 35.0; double GolfSimCamera::kMinQualityExposureLaunchAngle = -5.0; - double GolfSimCamera::kMaxPuttingQualityExposureLaunchAngle = +10.0; - double GolfSimCamera::kMinPuttingQualityExposureLaunchAngle = -10.0; - int GolfSimCamera::kNumberAngleCheckExposures = 3; + double GolfSimCamera::kMaxPuttingQualityExposureLaunchAngle = +8.0; + double GolfSimCamera::kMinPuttingQualityExposureLaunchAngle = -5.0; + int GolfSimCamera::kNumberAngleCheckExposures = 4; - double GolfSimCamera::kStandardBallSpeedSlowdownPercentage = 0.5; - double GolfSimCamera::kPracticeBallSpeedSlowdownPercentage = 2.0; - double GolfSimCamera::kPuttingBallSpeedSlowdownPercentage = 5.0; + double GolfSimCamera::kStandardBallSpeedSlowdownPercentage = 0.1; + double GolfSimCamera::kPracticeBallSpeedSlowdownPercentage = 4.0; + double GolfSimCamera::kPuttingBallSpeedSlowdownPercentage = 5.2; bool GolfSimCamera::kCameraRequiresFlushPulse = false; - int GolfSimCamera::kMaxBallsToRetain = 18; + int GolfSimCamera::kMaxBallsToRetain = 30; bool GolfSimCamera::kExternallyStrobedEnvFilterImage = true; int GolfSimCamera::kExternallyStrobedEnvBottomIgnoreHeight = 70; @@ -108,7 +108,7 @@ namespace golf_sim { int GolfSimCamera::kExternallyStrobedEnvLinesAngleUpper = 180; int GolfSimCamera::kExternallyStrobedEnvMinimumHoughLineLength = 23; - bool GolfSimCamera::kPlacedBallUseLargestBall = true; + bool GolfSimCamera::kPlacedBallUseLargestBall = false; CameraHardware::CameraModel GolfSimCamera::kSystemSlot1CameraType = CameraHardware::CameraModel::PiGS; CameraHardware::CameraModel GolfSimCamera::kSystemSlot2CameraType = CameraHardware::CameraModel::PiGS; @@ -119,7 +119,7 @@ namespace golf_sim { CameraHardware::CameraOrientation GolfSimCamera::kSystemSlot1CameraOrientation = CameraHardware::CameraOrientation::kUpsideUp; CameraHardware::CameraOrientation GolfSimCamera::kSystemSlot2CameraOrientation = CameraHardware::CameraOrientation::kUpsideUp; - bool GolfSimCamera::kUseOnlyHighQualityBallImagesForHLA = true; + bool GolfSimCamera::kUseOnlyHighQualityBallImagesForHLA = false; void GolfSimCamera::LoadConfigurationValues() { diff --git a/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp b/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp index 6ecc83d2..109fd386 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp @@ -25,13 +25,13 @@ namespace golf_sim { // that we would want when just watching the ball to see when it moves, // but the trade off is getting the club images. // Must call Configure() before these will be set. - uint GolfSimClubData::kClubImageWidthPixels = 200; - uint GolfSimClubData::kClubImageHeightPixels = 150; + uint GolfSimClubData::kClubImageWidthPixels = 340; + uint GolfSimClubData::kClubImageHeightPixels = 200; - uint GolfSimClubData::kNumberFramesToSaveBeforeHit = 4; - uint GolfSimClubData::kNumberFramesToSaveAfterHit = 4; + uint GolfSimClubData::kNumberFramesToSaveBeforeHit = 6; + uint GolfSimClubData::kNumberFramesToSaveAfterHit = 8; - float GolfSimClubData::kClubImageCameraGain = 30.0F; + float GolfSimClubData::kClubImageCameraGain = 40.0F; float GolfSimClubData::kClubImageShutterSpeedMultiplier = 0.4F; diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index e7e63006..2ceaa7eb 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -47,7 +47,7 @@ namespace golf_sim { GolfSimGlobals::golf_sim_running_ = false; } - static long kMaxCam2ImageReceivedTimeMs = 2000; + static long kMaxCam2ImageReceivedTimeMs = 40000; const int kWaitForBallPauseMs = 500; const int kEventLoopPauseMs = 5000; diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp index 5eb80c90..98f61c73 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp +++ b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp @@ -57,20 +57,20 @@ namespace golf_sim { double LibCameraInterface::kCamera1Saturation = 1.0; double LibCameraInterface::kCamera1HighFPSGain = 15.0; double LibCameraInterface::kCamera1Contrast = 1.0; - double LibCameraInterface::kCamera2Gain = 6.0; + double LibCameraInterface::kCamera2Gain = 3.0; double LibCameraInterface::kCamera2Saturation = 1.0; double LibCameraInterface::kCamera2ComparisonGain = 0.8; - double LibCameraInterface::kCamera2Contrast = 1.0; + double LibCameraInterface::kCamera2Contrast = 1.2; double LibCameraInterface::kCamera2CalibrateOrLocationGain = 1.0; double LibCameraInterface::kCamera2PuttingGain = 4.0; - double LibCameraInterface::kCamera2PuttingContrast = 1.0; + double LibCameraInterface::kCamera2PuttingContrast = 1.2; - long LibCameraInterface::kCamera1StillShutterTimeuS = 15000; + long LibCameraInterface::kCamera1StillShutterTimeuS = 40000; long LibCameraInterface::kCamera2StillShutterTimeuS = 15000; // Default values are based on empirical measurements using a 6mm lens - int kCroppedImagePixelOffsetLeft = -5; - int kCroppedImagePixelOffsetUp = -13; + int kCroppedImagePixelOffsetLeft = 0; + int kCroppedImagePixelOffsetUp = -3; // The system will start in a full-screen watching mode, but ensure // we set it up once just in case diff --git a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp index bb22d7c7..6a09b418 100644 --- a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp +++ b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp @@ -33,10 +33,10 @@ namespace golf_sim { std::vector PulseStrobe::pulse_intervals_fast_ms_; - int PulseStrobe::number_bits_for_fast_on_pulse_ = 0; + int PulseStrobe::number_bits_for_fast_on_pulse_ = 1; std::vector PulseStrobe::pulse_intervals_slow_ms_; - int PulseStrobe::number_bits_for_slow_on_pulse_ = 0; + int PulseStrobe::number_bits_for_slow_on_pulse_ = 1; // Currently true for both Pi and InnoMaker cameras // Should be set false if we are using the OG V1 Connector board, @@ -62,13 +62,13 @@ namespace golf_sim { bool PulseStrobe::spiOpen_ = false; bool PulseStrobe::kRecordAllImages = true; bool PulseStrobe::gpio_system_initialized_ = false; - int PulseStrobe::kPuttingStrobeDelayMs = 0; + int PulseStrobe::kPuttingStrobeDelayMs = 50; long PulseStrobe::kCam2SetupPeriodMilliseconds = 2000; int PulseStrobe::kNumberPrimingPulses = 12; int PulseStrobe::kPrimingPulseFPS = 15; - int PulseStrobe::kPauseToSetUpInnoMakerExternalTriggerMilliseconds = 1000; - int PulseStrobe::kPauseBeforeReadyForFinalPrimingPulseMs = 100; + int PulseStrobe::kPauseToSetUpInnoMakerExternalTriggerMilliseconds = 500; + int PulseStrobe::kPauseBeforeReadyForFinalPrimingPulseMs = 400; int PulseStrobe::kLastPulsePutterRepeats = 5; diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py index 08926806..6b24e706 100644 --- a/Software/web-server/tests/test_config_keys.py +++ b/Software/web-server/tests/test_config_keys.py @@ -1,4 +1,5 @@ -"""Every config key pitrac_lm reads must be defined in configurations.json.""" +"""Every config key pitrac_lm reads must be defined in configurations.json, and the C++ +initializers it falls back on must match the defaults there.""" import json import re @@ -36,6 +37,13 @@ # The calls above plus GetConfigString forwarding its tag_name NON_LITERAL_SET_CONSTANT_CALLS = 6 +MEMBER_DEF = re.compile(r"^[ \t]*(?:static\s+)?[\w:<>]+(?:[ \t]+[\w:<>]+)?[ \t]+(\w+)::(\w+)\s*=\s*([^;]+);", re.M) +PLAIN_DEF = re.compile(r"^[ \t]*(?:static\s+)?[\w:<>]+(?:[ \t]+[\w:<>]+)?[ \t]+(\w+)\s*=", re.M) +SET_CONSTANT = re.compile(r'SetConstant\(\s*"([^"]+)"\s*,\s*([\w:]+)\s*\)') + +# Comparison-mode override read into the same member as gs_config.strobing.number_bits_for_fast_on_pulse_ +SECOND_KEY_FOR_MEMBER = {"gs_config.testing.kExternallyStrobedEnvNumber_bits_for_fast_on_pulse_"} + def test_every_key_the_cpp_reads_is_in_the_schema(): source = "\n".join(p.read_text(errors="replace") for p in sorted(CPP_DIR.glob("*.cpp"))) @@ -53,3 +61,52 @@ def test_every_key_the_cpp_reads_is_in_the_schema(): settings = json.loads(SCHEMA.read_text())["settings"] missing = sorted((literal_keys | dynamic_keys) - settings.keys()) assert missing == [] + + +def cpp_literal(text): + """The value of a bool, number or string literal; None for anything else.""" + text = text.strip() + if text in ("true", "false"): + return text == "true" + if m := re.fullmatch(r'"((?:[^"\\]|\\.)*)"', text): + return re.sub(r"\\(.)", r"\1", m.group(1)) + if re.fullmatch(r"[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?[fFlLuU]*", text): + return float(text.rstrip("fFlLuU")) + return None + + +def same_value(cpp, default): + if isinstance(cpp, bool): + return cpp == (str(default).lower() in ("1", "true")) + if isinstance(cpp, float): + return cpp == float(default) + return cpp == str(default) + + +def test_cpp_member_initializers_match_the_schema_defaults(): + members, plain, sites = {}, set(), [] + for path in sorted(CPP_DIR.glob("*.cpp")): + text = re.sub(r"#ifdef _WIN32\n.*?#else\n", "", path.read_text(errors="replace"), flags=re.S) + for cls, name, value in MEMBER_DEF.findall(text): + members.setdefault(name, []).append((path.name, cls, value)) + plain |= {(path.name, name) for name in PLAIN_DEF.findall(text)} + sites += [(path.name, key, target) for key, target in SET_CONSTANT.findall(text)] + + settings = json.loads(SCHEMA.read_text())["settings"] + checked, drift = 0, [] + for file, key, target in sites: + *scope, name = target.split("::") + if scope: + defs = [d for d in members.get(name, []) if d[1] == scope[-1]] + else: + defs = [] if (file, name) in plain else [d for d in members.get(name, []) if d[0] == file] + default = settings.get(key, {}).get("default") + value = cpp_literal(defs[0][2]) if len(defs) == 1 else None + if value is None or default is None or isinstance(default, (list, dict)) or key in SECOND_KEY_FOR_MEMBER: + continue + checked += 1 + if not same_value(value, default): + drift.append(f"{key}: {defs[0][1]}::{name} = {defs[0][2].strip()}, schema default {default!r}") + + assert checked > 150 + assert drift == [] From f8f76bbb73e987a9fae2f3bc799107603f4ad7a8 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 13 Aug 2026 21:03:27 -0400 Subject: [PATCH 089/156] drop the kUsePreImageSubtraction re-reads the startup loader already covers --- Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp | 5 ----- Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp | 3 --- 2 files changed, 8 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp b/Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp index 22d9275a..9217106a 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp +++ b/Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp @@ -133,11 +133,6 @@ bool cam2_run_event_loop(LibcameraJpegApp& app, cv::Mat& returnImg, bool send_pr // processed. FlightCameraState state = kWaitingForFirstPrimingPulseGroup; - // Check here, once, to see if we are going to expect to produce a pre-image for later subtraction - golf_sim::GolfSimConfiguration::SetConstant("gs_config.ball_exposure_selection.kUsePreImageSubtraction", - golf_sim::GolfSimCamera::kUsePreImageSubtraction); - - // True if the InnoMaker camera external trigger script has not been called yet // Note - The InnoMaker camera needs its trigger script to be called AFTER the camera // has already started up. No idea why. diff --git a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp index 6a09b418..f0c2ce24 100644 --- a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp +++ b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp @@ -653,9 +653,6 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "Sent final priming pulse. Camera 2 should be primed at this point."); // Deal with a pre-image exposure if we need to (mostly deprecated - didn't work well) - GolfSimConfiguration::SetConstant("gs_config.ball_exposure_selection.kUsePreImageSubtraction", - GolfSimCamera::kUsePreImageSubtraction); - if (GolfSimCamera::kUsePreImageSubtraction) { GS_LOG_TRACE_MSG(trace, "Sent last priming pulse before pre-image."); From 5761860b57202b878f6c4f9b9f175ca765b3f103 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 14 Aug 2026 18:19:17 -0400 Subject: [PATCH 090/156] note that configurations.json owns the defaults --- Software/LMSourceCode/ImageProcessing/gs_config.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.h b/Software/LMSourceCode/ImageProcessing/gs_config.h index 8eda8405..e6fcdbb5 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.h +++ b/Software/LMSourceCode/ImageProcessing/gs_config.h @@ -51,13 +51,11 @@ namespace golf_sim { static PiModel GetPiModel(); - // Reads any values that need to be initialized early, such as static members of - // classes that won't otherwise have a good place to be otherwise initialized because, - // e.g., there's not constructor that will be called. static bool ReadValues(); static bool PropertyExists(const std::string& value_tag); + // The served config always wins; a variable's initializer applies only when the server omits the key. static void SetConstant(const std::string& value_tag, bool& constant_value); static void SetConstant(const std::string& value_tag, int& constant_value); static void SetConstant(const std::string& value_tag, long& constant_value); From e8bb2b9ee96cc646d69308c8895c96071dd5945f Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 14 Aug 2026 21:14:56 -0400 Subject: [PATCH 091/156] sync the function-local config fallbacks too and test globals and locals --- .../ImageProcessing/gs_config.cpp | 2 +- .../ImageProcessing/libcamera_interface.cpp | 12 ++-- .../LMSourceCode/ImageProcessing/lm_main.cpp | 2 +- .../ImageProcessing/pulse_strobe.cpp | 12 ++-- Software/web-server/tests/test_config_keys.py | 67 ++++++++++++------- 5 files changed, 56 insertions(+), 39 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index 25eb3bab..9d24d2ab 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -205,7 +205,7 @@ bool GolfSimConfiguration::ReadValues() { SetConstant("gs_config.cameras.kCamera2OffsetFromCamera1OriginMeters", GolfSimCamera::kCamera2OffsetFromCamera1OriginMeters); - int enclosure_type = 0; + int enclosure_type = 2; GolfSimConfiguration::SetConstant("gs_config.system.kEnclosureVersion", enclosure_type); kEnclosureVersion = (GolfSimConfiguration::EnclosureType)enclosure_type; diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp index 98f61c73..37c960e7 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp +++ b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp @@ -663,13 +663,13 @@ bool SendCameraCroppingCommand(const GolfSimCamera& camera, cv::Vec2i& cropping_ bool ConfigurePostProcessing(const cv::Vec2i& roi_size, const cv::Vec2i& roi_offset ) { - float kDifferenceM = 0.; - float kDifferenceC = 0.; - float kRegionThreshold = 0.; - float kMaxRegionThreshold = 0.; + float kDifferenceM = 0.6; + float kDifferenceC = 3.; + float kRegionThreshold = 0.05; + float kMaxRegionThreshold = 0.05; uint kFramePeriod = 0; - uint kHSkip = 0; - uint kVSkip = 0; + uint kHSkip = 2; + uint kVSkip = 2; GolfSimConfiguration::SetConstant("gs_config.motion_detect_stage.kDifferenceM", kDifferenceM); diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 67982be4..3338bc6b 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -1544,7 +1544,7 @@ int main(int argc, char *argv[]) // If we have a version 3 Connector Board, then we want to ensure // that it has been properly calibrated before we let the system // run. - int kConnectionBoardVersionIntValue = 0; + int kConnectionBoardVersionIntValue = 3; GolfSimConfiguration::SetConstant("gs_config.strobing.kConnectionBoardVersion", kConnectionBoardVersionIntValue); GolfSimConfiguration::ConnectionBoardType kConnectionBoardVersion = (GolfSimConfiguration::ConnectionBoardType)kConnectionBoardVersionIntValue; diff --git a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp index f0c2ce24..bb3479bb 100644 --- a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp +++ b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp @@ -486,7 +486,7 @@ namespace golf_sim { // The active-high setting will depend on which board the user sets - int kConnectionBoardVersionIntValue = 0; + int kConnectionBoardVersionIntValue = 3; GolfSimConfiguration::SetConstant("gs_config.strobing.kConnectionBoardVersion", kConnectionBoardVersionIntValue); GolfSimConfiguration::ConnectionBoardType kConnectionBoardVersion = (GolfSimConfiguration::ConnectionBoardType)kConnectionBoardVersionIntValue; @@ -598,7 +598,7 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.strobing.kPuttingStrobeDelayMs", kPuttingStrobeDelayMs); // Make sure we are sending pulses at a known speed. In this case, in the "fast" setting - unsigned int baud_rate = 0; + unsigned int baud_rate = 115200; GolfSimConfiguration::SetConstant("gs_config.strobing.kBaudRateForFastPulses", baud_rate); @@ -656,14 +656,14 @@ namespace golf_sim { if (GolfSimCamera::kUsePreImageSubtraction) { GS_LOG_TRACE_MSG(trace, "Sent last priming pulse before pre-image."); - long kPauseBeforeSendingPreImageTriggerMs = 0; + long kPauseBeforeSendingPreImageTriggerMs = 300; GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeSendingPreImageTriggerMs", kPauseBeforeSendingPreImageTriggerMs); usleep(kPauseBeforeSendingPreImageTriggerMs * 1000); SendCameraStrobeTriggerAndShutter(lggpio_chip_handle_); GS_LOG_TRACE_MSG(trace, "Sent pre-image trigger."); - long kPauseBeforeSendingImageFlushMs = 0; + long kPauseBeforeSendingImageFlushMs = 300; GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeSendingImageFlushMs", kPauseBeforeSendingImageFlushMs); usleep(kPauseBeforeSendingImageFlushMs * 1000); @@ -673,7 +673,7 @@ namespace golf_sim { // It will take the camera2 system a moment to package up the pre-image and send it to the object broker and to the // camera1 system (the one executing this code). Give it a chance - long kPauseAfterSendingPreImageTriggerMs = 0; + long kPauseAfterSendingPreImageTriggerMs = 2000; GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseAfterSendingPreImageTriggerMs", kPauseAfterSendingPreImageTriggerMs); usleep(kPauseAfterSendingPreImageTriggerMs * 1000); } @@ -712,7 +712,7 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "Waiting a moment to send flush trigger."); - long kPauseBeforeSendingImageFlushMs = 0; + long kPauseBeforeSendingImageFlushMs = 300; GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeSendingImageFlushMs", kPauseBeforeSendingImageFlushMs); usleep(kPauseBeforeSendingImageFlushMs * 1000); diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py index 6b24e706..7406493a 100644 --- a/Software/web-server/tests/test_config_keys.py +++ b/Software/web-server/tests/test_config_keys.py @@ -3,6 +3,7 @@ import json import re +from itertools import accumulate from pathlib import Path HERE = Path(__file__).parent @@ -37,12 +38,19 @@ # The calls above plus GetConfigString forwarding its tag_name NON_LITERAL_SET_CONSTANT_CALLS = 6 -MEMBER_DEF = re.compile(r"^[ \t]*(?:static\s+)?[\w:<>]+(?:[ \t]+[\w:<>]+)?[ \t]+(\w+)::(\w+)\s*=\s*([^;]+);", re.M) -PLAIN_DEF = re.compile(r"^[ \t]*(?:static\s+)?[\w:<>]+(?:[ \t]+[\w:<>]+)?[ \t]+(\w+)\s*=", re.M) +DEFINITION = re.compile(r"^[ \t]*(?:static\s+)?[\w:<>]+(?:[ \t]+[\w:<>]+)?[ \t]+(?:(\w+)::)?(\w+)\s*=\s*([^;]+);", re.M) SET_CONSTANT = re.compile(r'SetConstant\(\s*"([^"]+)"\s*,\s*([\w:]+)\s*\)') -# Comparison-mode override read into the same member as gs_config.strobing.number_bits_for_fast_on_pulse_ -SECOND_KEY_FOR_MEMBER = {"gs_config.testing.kExternallyStrobedEnvNumber_bits_for_fast_on_pulse_"} +# Keys whose C++ fallback stays off the schema default on purpose +NOT_SYNCED = { + # comparison-mode override read into the same member as gs_config.strobing.number_bits_for_fast_on_pulse_ + "gs_config.testing.kExternallyStrobedEnvNumber_bits_for_fast_on_pulse_", + # rig_type 0 is no rig, so a missing key fails auto-calibration instead of assuming one + "gs_config.calibration.kCalibrationRigType", + # kBaseTestDir is a placeholder global, and a Windows path in testProjection + "gs_config.logging.kLinuxBaseImageLoggingDir", + "gs_config.logging.kPCBaseImageLoggingDir", +} def test_every_key_the_cpp_reads_is_in_the_schema(): @@ -83,30 +91,39 @@ def same_value(cpp, default): return cpp == str(default) -def test_cpp_member_initializers_match_the_schema_defaults(): - members, plain, sites = {}, set(), [] - for path in sorted(CPP_DIR.glob("*.cpp")): - text = re.sub(r"#ifdef _WIN32\n.*?#else\n", "", path.read_text(errors="replace"), flags=re.S) - for cls, name, value in MEMBER_DEF.findall(text): - members.setdefault(name, []).append((path.name, cls, value)) - plain |= {(path.name, name) for name in PLAIN_DEF.findall(text)} - sites += [(path.name, key, target) for key, target in SET_CONSTANT.findall(text)] +def test_cpp_initializers_match_the_schema_defaults(): + sources = { + p.name: re.sub(r"#ifdef _WIN32\n.*?#else\n", "", p.read_text(errors="replace"), flags=re.S) + for p in sorted(CPP_DIR.glob("*.cpp")) + } + definitions = {file: list(DEFINITION.finditer(text)) for file, text in sources.items()} + members = [d.groups() for defs in definitions.values() for d in defs if d[1]] settings = json.loads(SCHEMA.read_text())["settings"] checked, drift = 0, [] - for file, key, target in sites: - *scope, name = target.split("::") - if scope: - defs = [d for d in members.get(name, []) if d[1] == scope[-1]] - else: - defs = [] if (file, name) in plain else [d for d in members.get(name, []) if d[0] == file] - default = settings.get(key, {}).get("default") - value = cpp_literal(defs[0][2]) if len(defs) == 1 else None - if value is None or default is None or isinstance(default, (list, dict)) or key in SECOND_KEY_FOR_MEMBER: - continue - checked += 1 - if not same_value(value, default): - drift.append(f"{key}: {defs[0][1]}::{name} = {defs[0][2].strip()}, schema default {default!r}") + for file, text in sources.items(): + depth = [0, *accumulate((c == "{") - (c == "}") for c in text)] + for site in SET_CONSTANT.finditer(text): + key, target = site.groups() + *scope, name = target.split("::") + if scope: + values = [value for cls, member, value in members if (cls, member) == (scope[-1], name)] + else: + # the nearest definition above the call whose scope has not closed by then + values = [ + d[3] + for d in definitions[file] + if d[2] == name + and d.start() < site.start() + and min(depth[d.start() : site.start()]) >= depth[d.start()] + ][-1:] + default = settings.get(key, {}).get("default") + value = cpp_literal(values[0]) if len(values) == 1 else None + if value is None or default is None or isinstance(default, (list, dict)) or key in NOT_SYNCED: + continue + checked += 1 + if not same_value(value, default): + drift.append(f"{key}: {target} = {values[0].strip()} in {file}, schema default {default!r}") assert checked > 150 assert drift == [] From 15cad8464db69591bad697273af5e9b0207cd426 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 14 Aug 2026 23:01:05 -0400 Subject: [PATCH 092/156] drop the stale 6mm lens note on the crop offsets --- Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp index 37c960e7..934b181e 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp +++ b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp @@ -68,7 +68,6 @@ namespace golf_sim { long LibCameraInterface::kCamera1StillShutterTimeuS = 40000; long LibCameraInterface::kCamera2StillShutterTimeuS = 15000; - // Default values are based on empirical measurements using a 6mm lens int kCroppedImagePixelOffsetLeft = 0; int kCroppedImagePixelOffsetUp = -3; From 84e0865401a9c4a4b2a608874be9f9056b835923 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 15 Aug 2026 20:26:46 -0400 Subject: [PATCH 093/156] type the int settings as integer and truncate them when serving --- Software/web-server/config_manager.py | 12 +- Software/web-server/configurations.json | 154 +++++++++--------- Software/web-server/static/js/config.js | 2 +- Software/web-server/tests/test_config_keys.py | 55 +++++-- .../web-server/tests/test_config_manager.py | 7 + 5 files changed, 135 insertions(+), 95 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index 73caa1e2..a0dd4e51 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -76,6 +76,14 @@ def _without_dotted_keys(nested: Dict[str, Any], dropped: List[str], prefix: str return clean +def _as_integer(value: Any) -> Any: + """Truncate toward zero like stoi did; boost's integer parse rejects "35.5" outright.""" + try: + return int(float(value)) + except (TypeError, ValueError, OverflowError): + return value + + def _unflatten(flat: Dict[str, Any]) -> Dict[str, Any]: nested: Dict[str, Any] = {} for key, value in flat.items(): @@ -589,7 +597,7 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: if not isinstance(value, bool) and value not in [True, False, "true", "false"]: return False, "Must be true or false" - elif setting_type == "number": + elif setting_type in ("number", "integer"): try: num_val = float(value) if "min" in setting_info and num_val < setting_info["min"]: @@ -641,6 +649,8 @@ def build_generated_config(self) -> Dict[str, Any]: json_settings_count = 0 for key, setting_info in settings_metadata.items(): value = self.get_config(key) + if setting_info.get("type") == "integer": + value = _as_integer(value) if value is not None: self._set_nested_json(config, key, value) json_settings_count += 1 diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 435a356f..da04f743 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -266,7 +266,7 @@ "subcategory": "advanced", "displayName": "Strobe DAC Setting", "description": "Calibrated DAC value for strobe LED current control. Set by the strobe calibration tool.", - "type": "number", + "type": "integer", "min": 0, "max": 255, "default": null, @@ -609,7 +609,7 @@ "subcategory": "advanced", "displayName": "Camera 1 Search Center X", "description": "X coordinate for ball search center in Camera 1 (calibration parameter)", - "type": "number", + "type": "integer", "min": 0, "max": 1920, "step": 10, @@ -621,7 +621,7 @@ "subcategory": "advanced", "displayName": "Camera 1 Search Center Y", "description": "Y coordinate for ball search center in Camera 1 (calibration parameter)", - "type": "number", + "type": "integer", "min": 0, "max": 1080, "step": 10, @@ -692,7 +692,7 @@ "basicSubcategory": "Simulators", "displayName": "GSPro Port", "description": "Port number for GSPro simulator", - "type": "number", + "type": "integer", "min": 1, "max": 65535, "default": 921, @@ -768,7 +768,7 @@ "basicSubcategory": "Ball Detection", "displayName": "CLAHE Clip Limit", "description": "Contrast enhancement strength (1-40)", - "type": "number", + "type": "integer", "min": 1, "max": 40, "default": 8, @@ -848,7 +848,7 @@ "subcategory": "advanced", "displayName": "Fast Strobe Pulse Width (bits)", "description": "Number of bits used for fast strobe on-pulse width. Controls the duration of the fast strobe pulse.", - "type": "number", + "type": "integer", "min": 1, "max": 16, "default": 1, @@ -859,7 +859,7 @@ "subcategory": "advanced", "displayName": "Slow Strobe Pulse Width (bits)", "description": "Number of bits used for slow strobe on-pulse width. Controls the duration of the slow strobe pulse.", - "type": "number", + "type": "integer", "min": 1, "max": 16, "default": 1, @@ -870,7 +870,7 @@ "subcategory": "advanced", "displayName": "Priming Pulse FPS", "description": "Frame rate for priming pulses sent to camera before capture. Used to stabilize camera exposure.", - "type": "number", + "type": "integer", "min": 5, "max": 60, "default": 15, @@ -993,7 +993,7 @@ "subcategory": "advanced", "displayName": "Model Input Width", "description": "Neural network input width in pixels (must match exported model, multiple of 32)", - "type": "number", + "type": "integer", "min": 320, "max": 2048, "step": 32, @@ -1005,7 +1005,7 @@ "subcategory": "advanced", "displayName": "Model Input Height", "description": "Neural network input height in pixels (must match exported model, multiple of 32)", - "type": "number", + "type": "integer", "min": 320, "max": 2048, "step": 32, @@ -1017,7 +1017,7 @@ "subcategory": "advanced", "displayName": "Inference Threads", "description": "Number of threads for inference (0 = auto)", - "type": "number", + "type": "integer", "min": 0, "max": 8, "step": 1, @@ -1051,7 +1051,7 @@ "subcategory": "advanced", "displayName": "CLAHE Tiles Grid Size", "description": "Grid size for CLAHE tiles", - "type": "number", + "type": "integer", "min": 2, "max": 16, "default": 6, @@ -1146,7 +1146,7 @@ "subcategory": "advanced", "displayName": "Camera 1 Still Shutter Time (\u03bcs)", "description": "Shutter time for Camera 1 still images in microseconds", - "type": "number", + "type": "integer", "min": 1000, "max": 100000, "default": 40000, @@ -1157,7 +1157,7 @@ "subcategory": "advanced", "displayName": "Camera 2 Still Shutter Time (\u03bcs)", "description": "Shutter time for Camera 2 still images in microseconds", - "type": "number", + "type": "integer", "min": 1000, "max": 100000, "default": 15000, @@ -1229,7 +1229,7 @@ "subcategory": "advanced", "displayName": "Min Radius Offset", "description": "Minimum radius offset in pixels", - "type": "number", + "type": "integer", "min": 0, "max": 50, "default": 20, @@ -1240,7 +1240,7 @@ "subcategory": "advanced", "displayName": "Max Radius Offset", "description": "Maximum radius offset in pixels", - "type": "number", + "type": "integer", "min": 0, "max": 50, "default": 20, @@ -1263,7 +1263,7 @@ "subcategory": "advanced", "displayName": "Baud Rate for Fast Pulses", "description": "Serial baud rate for fast strobe pulses", - "type": "number", + "type": "integer", "min": 9600, "max": 921600, "default": 115200, @@ -1274,7 +1274,7 @@ "subcategory": "advanced", "displayName": "Baud Rate for Slow Pulses", "description": "Serial baud rate for slow strobe pulses", - "type": "number", + "type": "integer", "min": 9600, "max": 921600, "default": 115200, @@ -1297,7 +1297,7 @@ "subcategory": "advanced", "displayName": "Camera 2 Setup Period (ms)", "description": "Setup period for Camera 2 in milliseconds", - "type": "number", + "type": "integer", "min": 500, "max": 5000, "default": 2000, @@ -1308,7 +1308,7 @@ "subcategory": "advanced", "displayName": "Number of Priming Pulses", "description": "Number of priming pulses before capture", - "type": "number", + "type": "integer", "min": 1, "max": 30, "default": 12, @@ -1319,7 +1319,7 @@ "subcategory": "advanced", "displayName": "Putting Strobe Delay (ms)", "description": "Strobe delay for putting mode in milliseconds", - "type": "number", + "type": "integer", "min": 10, "max": 200, "default": 50, @@ -1330,7 +1330,7 @@ "subcategory": "advanced", "displayName": "Pause Before Final Priming Pulse (ms)", "description": "Pause before ready for final priming pulse in milliseconds", - "type": "number", + "type": "integer", "min": 100, "max": 1000, "default": 400, @@ -1341,7 +1341,7 @@ "subcategory": "advanced", "displayName": "Pause Before Pre-Image Trigger (ms)", "description": "Pause before sending pre-image trigger in milliseconds", - "type": "number", + "type": "integer", "min": 100, "max": 1000, "default": 300, @@ -1352,7 +1352,7 @@ "subcategory": "advanced", "displayName": "Pause After Pre-Image Trigger (ms)", "description": "Pause after sending pre-image trigger in milliseconds", - "type": "number", + "type": "integer", "min": 500, "max": 5000, "default": 2000, @@ -1363,7 +1363,7 @@ "subcategory": "advanced", "displayName": "Pause Before Image Flush (ms)", "description": "Pause before sending image flush in milliseconds", - "type": "number", + "type": "integer", "min": 100, "max": 1000, "default": 300, @@ -1374,7 +1374,7 @@ "subcategory": "advanced", "displayName": "InnoMaker External Trigger Setup (ms)", "description": "Pause to set up InnoMaker external trigger in milliseconds", - "type": "number", + "type": "integer", "min": 100, "max": 2000, "default": 500, @@ -1385,7 +1385,7 @@ "subcategory": "advanced", "displayName": "Max Watching Crop Width", "description": "Maximum width for watching crop area in pixels", - "type": "number", + "type": "integer", "min": 32, "max": 256, "default": 96, @@ -1396,7 +1396,7 @@ "subcategory": "advanced", "displayName": "Max Watching Crop Height", "description": "Maximum height for watching crop area in pixels", - "type": "number", + "type": "integer", "min": 32, "max": 256, "default": 88, @@ -1421,7 +1421,7 @@ "subcategory": "advanced", "displayName": "Coarse Search Resolution", "description": "Resolution (pixels) for downsampled dimple images during coarse spin search. Lower is faster, higher preserves more detail.", - "type": "number", + "type": "integer", "min": 45, "max": 120, "default": 90, @@ -1432,7 +1432,7 @@ "subcategory": "advanced", "displayName": "Gabor Max White Percent", "description": "Maximum white percentage for Gabor filter", - "type": "number", + "type": "integer", "min": 30, "max": 60, "default": 45, @@ -1443,7 +1443,7 @@ "subcategory": "advanced", "displayName": "Gabor Min White Percent", "description": "Minimum white percentage for Gabor filter", - "type": "number", + "type": "integer", "min": 20, "max": 50, "default": 39, @@ -1454,7 +1454,7 @@ "subcategory": "advanced", "displayName": "Coarse X Rotation Increment", "description": "Increment for coarse X rotation in degrees", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 4, @@ -1465,7 +1465,7 @@ "subcategory": "advanced", "displayName": "Coarse X Rotation Start", "description": "Starting angle for coarse X rotation in degrees", - "type": "number", + "type": "integer", "min": -90, "max": 0, "default": -36, @@ -1476,7 +1476,7 @@ "subcategory": "advanced", "displayName": "Coarse X Rotation End", "description": "Ending angle for coarse X rotation in degrees", - "type": "number", + "type": "integer", "min": 0, "max": 90, "default": 36, @@ -1487,7 +1487,7 @@ "subcategory": "advanced", "displayName": "Coarse Y Rotation Increment", "description": "Increment for coarse Y rotation in degrees", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 5, @@ -1498,7 +1498,7 @@ "subcategory": "advanced", "displayName": "Coarse Y Rotation Start", "description": "Starting angle for coarse Y rotation in degrees", - "type": "number", + "type": "integer", "min": -45, "max": 0, "default": -15, @@ -1509,7 +1509,7 @@ "subcategory": "advanced", "displayName": "Coarse Y Rotation End", "description": "Ending angle for coarse Y rotation in degrees", - "type": "number", + "type": "integer", "min": 0, "max": 45, "default": 15, @@ -1520,7 +1520,7 @@ "subcategory": "advanced", "displayName": "Coarse Z Rotation Increment", "description": "Increment for coarse Z rotation in degrees", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 4, @@ -1531,7 +1531,7 @@ "subcategory": "advanced", "displayName": "Coarse Z Rotation Start", "description": "Starting angle for coarse Z rotation in degrees", - "type": "number", + "type": "integer", "min": -90, "max": 0, "default": -10, @@ -1542,7 +1542,7 @@ "subcategory": "advanced", "displayName": "Coarse Z Rotation End", "description": "Ending angle for coarse Z rotation in degrees", - "type": "number", + "type": "integer", "min": 0, "max": 180, "default": 110, @@ -1574,7 +1574,7 @@ "subcategory": "advanced", "displayName": "Max Camera 2 Image Receive Time (ms)", "description": "Maximum time to wait for Camera 2 image in milliseconds", - "type": "number", + "type": "integer", "min": 1000, "max": 60000, "default": 40000, @@ -1585,7 +1585,7 @@ "subcategory": "advanced", "displayName": "E6 Inter-Message Delay (ms)", "description": "Delay between E6 messages in milliseconds", - "type": "number", + "type": "integer", "min": 0, "max": 500, "default": 50, @@ -1614,7 +1614,7 @@ "subcategory": "advanced", "displayName": "Frames Before Hit", "description": "Number of frames to save before club hits ball", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 6, @@ -1625,7 +1625,7 @@ "subcategory": "advanced", "displayName": "Frames After Hit", "description": "Number of frames to save after club hits ball", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 8, @@ -1636,7 +1636,7 @@ "subcategory": "advanced", "displayName": "Club Image Width", "description": "Width of club images in pixels", - "type": "number", + "type": "integer", "min": 100, "max": 1920, "default": 340, @@ -1647,7 +1647,7 @@ "subcategory": "advanced", "displayName": "Club Image Height", "description": "Height of club images in pixels", - "type": "number", + "type": "integer", "min": 100, "max": 1080, "default": 200, @@ -1681,7 +1681,7 @@ "subcategory": "advanced", "displayName": "Strobed Balls Min Hough Circles", "description": "Minimum number of circles to return from HoughCircles for strobed balls", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 6, @@ -1692,7 +1692,7 @@ "subcategory": "advanced", "displayName": "Strobed Balls Max Hough Circles", "description": "Maximum number of circles to return from HoughCircles for strobed balls", - "type": "number", + "type": "integer", "min": 5, "max": 50, "default": 20, @@ -1770,7 +1770,7 @@ "subcategory": "advanced", "displayName": "Strobed Pre-Canny Blur Size", "description": "Blur kernel size before Canny edge detection", - "type": "number", + "type": "integer", "min": 1, "max": 15, "step": 2, @@ -1782,7 +1782,7 @@ "subcategory": "advanced", "displayName": "Strobed Pre-Hough Blur Size", "description": "Blur kernel size before HoughCircles (must be odd)", - "type": "number", + "type": "integer", "min": 1, "max": 25, "step": 2, @@ -1862,7 +1862,7 @@ "subcategory": "advanced", "displayName": "Putting Min Hough Circles", "description": "Minimum circles to return in putting mode", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 6, @@ -1873,7 +1873,7 @@ "subcategory": "advanced", "displayName": "Putting Max Hough Circles", "description": "Maximum circles to return in putting mode", - "type": "number", + "type": "integer", "min": 10, "max": 50, "default": 25, @@ -1896,7 +1896,7 @@ "subcategory": "advanced", "displayName": "Putting Pre-Hough Blur Size", "description": "Blur kernel size before HoughCircles in putting mode", - "type": "number", + "type": "integer", "min": 1, "max": 25, "step": 2, @@ -1932,7 +1932,7 @@ "subcategory": "advanced", "displayName": "High Quality Balls to Retain", "description": "Number of high quality ball detections to keep", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 2, @@ -1943,7 +1943,7 @@ "subcategory": "advanced", "displayName": "Max Off-Trajectory Distance", "description": "Maximum allowed distance from expected trajectory", - "type": "number", + "type": "integer", "min": 1, "max": 50, "default": 8, @@ -2242,7 +2242,7 @@ "subcategory": "advanced", "displayName": "Alt Algorithm Pre-Canny Blur", "description": "Blur size before Canny edge detection in alternative algorithm", - "type": "number", + "type": "integer", "min": 1, "max": 21, "step": 2, @@ -2254,7 +2254,7 @@ "subcategory": "advanced", "displayName": "Alt Algorithm Pre-Hough Blur", "description": "Blur size before HoughCircles in alternative algorithm (must be odd)", - "type": "number", + "type": "integer", "min": 1, "max": 31, "step": 2, @@ -2359,7 +2359,7 @@ "subcategory": "advanced", "displayName": "Placed Ball Pre-Canny Blur", "description": "Blur size before Canny for placed ball", - "type": "number", + "type": "integer", "min": 1, "max": 21, "step": 2, @@ -2371,7 +2371,7 @@ "subcategory": "advanced", "displayName": "Placed Ball Pre-Hough Blur", "description": "Blur size before HoughCircles for placed ball", - "type": "number", + "type": "integer", "min": 1, "max": 25, "step": 2, @@ -2383,7 +2383,7 @@ "subcategory": "advanced", "displayName": "Placed Ball Min Circles", "description": "Minimum circles to return for placed ball", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 1, @@ -2394,7 +2394,7 @@ "subcategory": "advanced", "displayName": "Placed Ball Max Circles", "description": "Maximum circles to return for placed ball", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 4, @@ -2454,7 +2454,7 @@ "subcategory": "advanced", "displayName": "Best Circle Pre-Canny Blur", "description": "Blur size before Canny in best circle algorithm", - "type": "number", + "type": "integer", "min": 1, "max": 15, "step": 2, @@ -2466,7 +2466,7 @@ "subcategory": "advanced", "displayName": "Best Circle Pre-Hough Blur", "description": "Blur size before HoughCircles in best circle algorithm", - "type": "number", + "type": "integer", "min": 1, "max": 25, "step": 2, @@ -2546,7 +2546,7 @@ "subcategory": "advanced", "displayName": "Number Radii to Average", "description": "Number of radii to average for dynamic adjustment", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 2, @@ -2675,7 +2675,7 @@ "subcategory": "advanced", "displayName": "Ball Pair Edge Backoff", "description": "Edge backoff in pixels for closest ball pairs", - "type": "number", + "type": "integer", "min": 50, "max": 500, "default": 200, @@ -2734,7 +2734,7 @@ "subcategory": "advanced", "displayName": "Max Balls to Retain", "description": "Maximum number of ball detections to keep", - "type": "number", + "type": "integer", "min": 10, "max": 100, "default": 30, @@ -2845,7 +2845,7 @@ "subcategory": "advanced", "displayName": "Number Angle Check Exposures", "description": "Number of exposures to check for angle validation", - "type": "number", + "type": "integer", "min": 2, "max": 10, "default": 4, @@ -2925,7 +2925,7 @@ "subcategory": "advanced", "displayName": "Motion Frame Period", "description": "Frame period for motion detection", - "type": "number", + "type": "integer", "min": 0, "max": 10, "default": 0, @@ -2936,7 +2936,7 @@ "subcategory": "advanced", "displayName": "Motion H Skip", "description": "Horizontal skip for motion detection", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 2, @@ -2947,7 +2947,7 @@ "subcategory": "advanced", "displayName": "Motion V Skip", "description": "Vertical skip for motion detection", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 2, @@ -2958,7 +2958,7 @@ "subcategory": "advanced", "displayName": "Cropped Image Offset Left", "description": "Left offset for cropped image in motion detection", - "type": "number", + "type": "integer", "min": -50, "max": 50, "default": 0, @@ -2969,7 +2969,7 @@ "subcategory": "advanced", "displayName": "Cropped Image Offset Up", "description": "Up offset for cropped image in motion detection", - "type": "number", + "type": "integer", "min": -50, "max": 50, "default": -3, @@ -3035,7 +3035,7 @@ "subcategory": "advanced", "displayName": "Pictures for Focal Length Average", "description": "Number of pictures to average for focal length calibration", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 6, @@ -3046,7 +3046,7 @@ "subcategory": "advanced", "displayName": "Number Of Calibration Failures To Tolerate", "description": "Allows a few ball-ID failures to be skipped over and re-tried.", - "type": "number", + "type": "integer", "min": 0, "max": 20, "default": 4, @@ -3081,14 +3081,14 @@ "ui_name": "Backspin Tolerance" }, "gs_config.testing.kAutomatedTestToleranceBallSpeedMPH": { - "type": "integer", + "type": "number", "default": 4, "description": "Ball speed tolerance for automated testing (MPH)", "category": "testing", "ui_name": "Ball Speed Tolerance" }, "gs_config.testing.kAutomatedTestToleranceHLA": { - "type": "integer", + "type": "number", "default": 3, "description": "Horizontal launch angle tolerance for automated testing (degrees)", "category": "testing", @@ -3102,7 +3102,7 @@ "ui_name": "Sidespin Tolerance" }, "gs_config.testing.kAutomatedTestToleranceVLA": { - "type": "integer", + "type": "number", "default": 2, "description": "Vertical launch angle tolerance for automated testing (degrees)", "category": "testing", diff --git a/Software/web-server/static/js/config.js b/Software/web-server/static/js/config.js index b21dc9c7..6af9b7c9 100644 --- a/Software/web-server/static/js/config.js +++ b/Software/web-server/static/js/config.js @@ -1160,7 +1160,7 @@ async function validateInput(key, value, errorElement) { try { const metadata = configMetadata[key] || {}; - if (metadata.type === 'number') { + if (metadata.type === 'number' || metadata.type === 'integer') { const num = parseFloat(value); if (isNaN(num)) { errorElement.textContent = 'Must be a valid number'; diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py index 7406493a..9e56c5b0 100644 --- a/Software/web-server/tests/test_config_keys.py +++ b/Software/web-server/tests/test_config_keys.py @@ -38,7 +38,8 @@ # The calls above plus GetConfigString forwarding its tag_name NON_LITERAL_SET_CONSTANT_CALLS = 6 -DEFINITION = re.compile(r"^[ \t]*(?:static\s+)?[\w:<>]+(?:[ \t]+[\w:<>]+)?[ \t]+(?:(\w+)::)?(\w+)\s*=\s*([^;]+);", re.M) +DEFINITION = re.compile(r"^[ \t]*(?:static\s+)?([\w:<>]+(?:[ \t]+[\w:<>]+)?)[ \t]+(?:(\w+)::)?(\w+)\s*=\s*([^;]+);", re.M) +INTEGER_TYPES = {"int", "long", "unsigned int", "uint"} SET_CONSTANT = re.compile(r'SetConstant\(\s*"([^"]+)"\s*,\s*([\w:]+)\s*\)') # Keys whose C++ fallback stays off the schema default on purpose @@ -91,39 +92,61 @@ def same_value(cpp, default): return cpp == str(default) -def test_cpp_initializers_match_the_schema_defaults(): +def set_constant_sites(): + """(file, key, target, C++ type, initializer text) for each literal-key SetConstant whose target definition is found.""" sources = { p.name: re.sub(r"#ifdef _WIN32\n.*?#else\n", "", p.read_text(errors="replace"), flags=re.S) for p in sorted(CPP_DIR.glob("*.cpp")) } definitions = {file: list(DEFINITION.finditer(text)) for file, text in sources.items()} - members = [d.groups() for defs in definitions.values() for d in defs if d[1]] + members = [d.groups() for defs in definitions.values() for d in defs if d[2]] - settings = json.loads(SCHEMA.read_text())["settings"] - checked, drift = 0, [] for file, text in sources.items(): depth = [0, *accumulate((c == "{") - (c == "}") for c in text)] for site in SET_CONSTANT.finditer(text): key, target = site.groups() *scope, name = target.split("::") if scope: - values = [value for cls, member, value in members if (cls, member) == (scope[-1], name)] + found = [(typ, value) for typ, cls, member, value in members if (cls, member) == (scope[-1], name)] else: # the nearest definition above the call whose scope has not closed by then - values = [ - d[3] + found = [ + (d[1], d[4]) for d in definitions[file] - if d[2] == name + if d[3] == name and d.start() < site.start() and min(depth[d.start() : site.start()]) >= depth[d.start()] ][-1:] - default = settings.get(key, {}).get("default") - value = cpp_literal(values[0]) if len(values) == 1 else None - if value is None or default is None or isinstance(default, (list, dict)) or key in NOT_SYNCED: - continue - checked += 1 - if not same_value(value, default): - drift.append(f"{key}: {target} = {values[0].strip()} in {file}, schema default {default!r}") + if len(found) == 1: + yield file, key, target, *found[0] + + +def test_cpp_initializers_match_the_schema_defaults(): + settings = json.loads(SCHEMA.read_text())["settings"] + checked, drift = 0, [] + for file, key, target, _, initializer in set_constant_sites(): + default = settings.get(key, {}).get("default") + value = cpp_literal(initializer) + if value is None or default is None or isinstance(default, (list, dict)) or key in NOT_SYNCED: + continue + checked += 1 + if not same_value(value, default): + drift.append(f"{key}: {target} = {initializer.strip()} in {file}, schema default {default!r}") assert checked > 150 assert drift == [] + + +def integer_typed(setting): + if setting.get("type") == "select": + return all(re.fullmatch(r"-?\d+", option) for option in setting.get("options", {})) + return setting.get("type") == "integer" + + +def test_integer_cpp_targets_have_integer_schema_types(): + """The server truncates integer-typed values for boost's integer parse, which rejects "35.5".""" + settings = json.loads(SCHEMA.read_text())["settings"] + integer_sites = [(key, typ) for _, key, _, typ, _ in set_constant_sites() if typ in INTEGER_TYPES] + assert len(integer_sites) > 60 + untyped = sorted({key for key, _ in integer_sites if key in settings and not integer_typed(settings[key])}) + assert untyped == [] diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index 56cb11aa..b18f8abf 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -777,6 +777,13 @@ def test_build_generated_config_merges_transient_overrides(self, config_manager) cfg = config_manager.build_generated_config() assert cfg["gs_config"]["testing"]["kBaseTestImageDir"] == "./Images/" + def test_build_generated_config_truncates_integer_settings(self, config_manager): + config_manager.set_config("gs_config.strobing.kPrimingPulseFPS", "35.5") + config_manager.set_config("gs_config.ipc_interface.kMaxCam2ImageReceivedTimeMs", 30000.0) + cfg = config_manager.build_generated_config() + assert cfg["gs_config"]["strobing"]["kPrimingPulseFPS"] == "35" + assert cfg["gs_config"]["ipc_interface"]["kMaxCam2ImageReceivedTimeMs"] == "30000" + def test_build_generated_config_includes_former_cli_and_env(self, config_manager): cfg = config_manager.build_generated_config() # former CLI setting — gs_config.player.kGolferOrientation From b7573d45400bf443d1185329f3088d25aaf2a858 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 16 Aug 2026 16:38:45 -0400 Subject: [PATCH 094/156] spell out the per-camera config keys so the key test sees every one --- .../ImageProcessing/camera_hardware.cpp | 21 +++++---- .../ImageProcessing/gs_calibration.cpp | 7 ++- Software/web-server/tests/test_config_keys.py | 47 +++++-------------- 3 files changed, 25 insertions(+), 50 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp index 43adbfde..b8223e57 100644 --- a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp +++ b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp @@ -265,11 +265,11 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "Video resolution (x,y) is: " + std::to_string(video_resolution_x_) + "/" + std::to_string(video_resolution_y_) + "."); // Attempt to get the expected ball radius from the .json file - std::string ball_radius_pixels_at_40cm_name = "kExpectedBallRadiusPixelsAt40cmCamera" + std::string(camera_number == GsCameraNumber::kGsCamera1 ? "1" : "2"); + std::string ball_radius_pixels_at_40cm_name = camera_number == GsCameraNumber::kGsCamera1 ? "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera1" : "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera2"; int expected_ball_radius_pixels_at_40cm_from_config_file = -1; - GolfSimConfiguration::SetConstant("gs_config.cameras." + ball_radius_pixels_at_40cm_name, expected_ball_radius_pixels_at_40cm_from_config_file); + GolfSimConfiguration::SetConstant(ball_radius_pixels_at_40cm_name, expected_ball_radius_pixels_at_40cm_from_config_file); // The default will be used unless the .json file has a value, in which case that value will be used. @@ -290,12 +290,13 @@ namespace golf_sim { camera_distortion_values.cols, camera_distortion_values.type()); - std::string calibration_element_name = "kCamera" + std::to_string((int)camera_number_) + "CalibrationMatrix"; - std::string distortion_element_name = "kCamera" + std::to_string((int)camera_number_) + "DistortionVector"; + const bool is_camera1 = camera_number_ == GsCameraNumber::kGsCamera1; + std::string calibration_element_name = is_camera1 ? "gs_config.cameras.kCamera1CalibrationMatrix" : "gs_config.cameras.kCamera2CalibrationMatrix"; + std::string distortion_element_name = is_camera1 ? "gs_config.cameras.kCamera1DistortionVector" : "gs_config.cameras.kCamera2DistortionVector"; - if (GolfSimConfiguration::PropertyExists("gs_config.cameras." + calibration_element_name) ) { - GolfSimConfiguration::SetConstant("gs_config.cameras." + calibration_element_name, camera_calibration_matrix_values); - GolfSimConfiguration::SetConstant("gs_config.cameras." + distortion_element_name, camera_distortion_values); + if (GolfSimConfiguration::PropertyExists(calibration_element_name) ) { + GolfSimConfiguration::SetConstant(calibration_element_name, camera_calibration_matrix_values); + GolfSimConfiguration::SetConstant(distortion_element_name, camera_distortion_values); } bool calibration_information_is_valid = (camera_calibration_matrix_values.at(0,0) != 0.0) && @@ -478,18 +479,18 @@ namespace golf_sim { // Only if we are using a custom focal length, then use that length if (lens_type == Lens_Custom) { - tag = "cameras.kSlot" + std::to_string(camera_number_) + "CustomLensFocalLength"; + tag = camera_number_ == GsCameraNumber::kGsCamera1 ? "cameras.kSlot1CustomLensFocalLength" : "cameras.kSlot2CustomLensFocalLength"; } else { // Otherwise, for non-custom lenses, just use the standard focal length parameter - tag = "gs_config.cameras.kCamera" + std::to_string(camera_number_) + "FocalLength"; + tag = camera_number_ == GsCameraNumber::kGsCamera1 ? "gs_config.cameras.kCamera1FocalLength" : "gs_config.cameras.kCamera2FocalLength"; } GolfSimConfiguration::SetConstant(tag, focal_length_); GS_LOG_TRACE_MSG(trace, "Focal length = " + std::to_string(focal_length_)); } - tag = "gs_config.cameras.kCamera" + std::to_string(camera_number_) + "Angles"; + tag = camera_number_ == GsCameraNumber::kGsCamera1 ? "gs_config.cameras.kCamera1Angles" : "gs_config.cameras.kCamera2Angles"; GolfSimConfiguration::SetConstant(tag, camera_angles_); cameraInitialized = true; diff --git a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp index 3d80f624..465f7413 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp @@ -479,10 +479,9 @@ namespace golf_sim { return false; } - std::string camera_number_string = std::to_string(camera_number); - - std::string focal_length_tag_name = "gs_config.cameras.kCamera" + camera_number_string + "FocalLength"; - std::string camera_angles_tag_name = "gs_config.cameras.kCamera" + camera_number_string + "Angles"; + const bool is_camera1 = camera_number == GsCameraNumber::kGsCamera1; + std::string focal_length_tag_name = is_camera1 ? "gs_config.cameras.kCamera1FocalLength" : "gs_config.cameras.kCamera2FocalLength"; + std::string camera_angles_tag_name = is_camera1 ? "gs_config.cameras.kCamera1Angles" : "gs_config.cameras.kCamera2Angles"; #ifdef __unix__ std::vector angles_vector = {camera_angles[0], camera_angles[1]}; diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py index 9e56c5b0..8e9b637d 100644 --- a/Software/web-server/tests/test_config_keys.py +++ b/Software/web-server/tests/test_config_keys.py @@ -10,34 +10,16 @@ CPP_DIR = HERE / "../../LMSourceCode/ImageProcessing" SCHEMA = HERE.parent / "configurations.json" -# Keys camera_hardware.cpp builds at runtime, {n} = camera 1 and 2, with the source text that builds each -DYNAMIC_KEYS = { - "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera{n}": [ - '"gs_config.cameras." + ball_radius_pixels_at_40cm_name', - '"kExpectedBallRadiusPixelsAt40cmCamera" + std::string(camera_number == GsCameraNumber::kGsCamera1 ? "1" : "2")', - ], - "gs_config.cameras.kCamera{n}CalibrationMatrix": [ - '"gs_config.cameras." + calibration_element_name', - '"kCamera" + std::to_string((int)camera_number_) + "CalibrationMatrix"', - ], - "gs_config.cameras.kCamera{n}DistortionVector": [ - '"gs_config.cameras." + distortion_element_name', - '"kCamera" + std::to_string((int)camera_number_) + "DistortionVector"', - ], - "cameras.kSlot{n}CustomLensFocalLength": [ - '"cameras.kSlot" + std::to_string(camera_number_) + "CustomLensFocalLength"', - ], - "gs_config.cameras.kCamera{n}FocalLength": [ - '"gs_config.cameras.kCamera" + std::to_string(camera_number_) + "FocalLength"', - ], - "gs_config.cameras.kCamera{n}Angles": [ - '"gs_config.cameras.kCamera" + std::to_string(camera_number_) + "Angles"', - ], +# Every whole-key literal in the C++; a key prefix left for runtime concatenation shows up as a key the schema lacks +KEY_LITERAL = re.compile(r'(?]+(?:[ \t]+[\w:<>]+)?)[ \t]+(?:(\w+)::)?(\w+)\s*=\s*([^;]+);", re.M) INTEGER_TYPES = {"int", "long", "unsigned int", "uint"} SET_CONSTANT = re.compile(r'SetConstant\(\s*"([^"]+)"\s*,\s*([\w:]+)\s*\)') @@ -57,18 +39,11 @@ def test_every_key_the_cpp_reads_is_in_the_schema(): source = "\n".join(p.read_text(errors="replace") for p in sorted(CPP_DIR.glob("*.cpp"))) - literal_keys = set(re.findall(r'(?:SetConstant|GetConfigString|apply_enum)\(\s*"([^"]+)"\s*[,)]', source)) - assert len(literal_keys) > 200 - - non_literal_calls = re.findall(r'SetConstant\((?!\s*const\b)(?!\s*"[^"]+"\s*,)', source) - assert len(non_literal_calls) == NON_LITERAL_SET_CONSTANT_CALLS, "update DYNAMIC_KEYS for the new runtime-built key" - - stale = [f for fragments in DYNAMIC_KEYS.values() for f in fragments if f not in source] - assert stale == [] - dynamic_keys = {template.format(n=n) for template in DYNAMIC_KEYS for n in (1, 2)} + keys = set(CALL_LITERAL.findall(source)) | set(KEY_LITERAL.findall(source)) + assert len(keys) > 200 settings = json.loads(SCHEMA.read_text())["settings"] - missing = sorted((literal_keys | dynamic_keys) - settings.keys()) + missing = sorted(keys - settings.keys() - NOT_IN_SCHEMA) assert missing == [] From dd80c98b48245faa9b6838594c7dd51aa5736ae4 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sun, 16 Aug 2026 20:06:54 -0400 Subject: [PATCH 095/156] read the BallImageProc settings once in its startup loader --- .../ImageProcessing/ball_image_proc.cpp | 264 +++++++++--------- 1 file changed, 130 insertions(+), 134 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp b/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp index 0959d82e..1213ae42 100644 --- a/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp +++ b/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp @@ -239,140 +239,6 @@ namespace golf_sim { min_ball_radius_ = -1; max_ball_radius_ = -1; - // The following constants are only used internal to the GolfSimCamera class, and so can be initialized in the constructor - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesIncrement", kCoarseXRotationDegreesIncrement); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesStart", kCoarseXRotationDegreesStart); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesEnd", kCoarseXRotationDegreesEnd); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesIncrement", kCoarseYRotationDegreesIncrement); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesStart", kCoarseYRotationDegreesStart); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesEnd", kCoarseYRotationDegreesEnd); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesIncrement", kCoarseZRotationDegreesIncrement); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesStart", kCoarseZRotationDegreesStart); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesEnd", kCoarseZRotationDegreesEnd); - - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseSearchResolution", kCoarseSearchResolution); - - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kGaborMinWhitePercent", kGaborMinWhitePercent); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kGaborMaxWhitePercent", kGaborMaxWhitePercent); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCannyLower", kPlacedBallCannyLower); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCannyUpper", kPlacedBallCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallStartingParam2", kPlacedBallStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallMinParam2", kPlacedBallMinParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallMaxParam2", kPlacedBallMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCurrentParam1", kPlacedBallCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallParam2Increment", kPlacedBallParam2Increment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedMinHoughReturnCircles", kPlacedMinHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedMaxHoughReturnCircles", kPlacedMaxHoughReturnCircles); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCannyLower", kStrobedBallsCannyLower); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCannyUpper", kStrobedBallsCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreCannyBlurSize", kStrobedBallsPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreHoughBlurSize", kStrobedBallsPreHoughBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsStartingParam2", kStrobedBallsStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMinParam2", kStrobedBallsMinParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMaxParam2", kStrobedBallsMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCurrentParam1", kStrobedBallsCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsParam2Increment", kStrobedBallsParam2Increment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMinHoughReturnCircles", kStrobedBallsMinHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMaxHoughReturnCircles", kStrobedBallsMaxHoughReturnCircles); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsUseAltHoughAlgorithm", kStrobedBallsUseAltHoughAlgorithm); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCannyLower", kStrobedBallsAltCannyLower); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCannyUpper", kStrobedBallsAltCannyUpper); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltPreCannyBlurSize", kStrobedBallsAltPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltPreHoughBlurSize", kStrobedBallsAltPreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltStartingParam2", kStrobedBallsAltStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltMinParam2", kStrobedBallsAltMinParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltMaxParam2", kStrobedBallsAltMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCurrentParam1", kStrobedBallsAltCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltHoughDpParam1", kStrobedBallsAltHoughDpParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltParam2Increment", kStrobedBallsAltParam2Increment); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseCLAHEProcessing", kUseCLAHEProcessing); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kCLAHEClipLimit", kCLAHEClipLimit); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kCLAHETilesGridSize", kCLAHETilesGridSize); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallStartingParam2", kPuttingBallStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallMinParam2", kPuttingBallMinParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallMaxParam2", kPuttingBallMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallCurrentParam1", kPuttingBallCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallParam2Increment", kPuttingBallParam2Increment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingMinHoughReturnCircles", kPuttingMinHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingMaxHoughReturnCircles", kPuttingMaxHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingHoughDpParam1", kPuttingHoughDpParam1); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCurrentParam1", kExternallyStrobedEnvCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaxParam2", kExternallyStrobedEnvMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvStartingParam2", kExternallyStrobedEnvStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingParam2", kExternallyStrobedEnvNarrowingParam2); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingDpParam", kExternallyStrobedEnvNarrowingDpParam); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvParam2Increment", kExternallyStrobedEnvParam2Increment); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinHoughReturnCircles", kExternallyStrobedEnvMinHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaxHoughReturnCircles", kExternallyStrobedEnvMaxHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize", kExternallyStrobedEnvPreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCirclePreHoughBlurSize", kExternallyStrobedBestCirclePreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam1", kExternallyStrobedBestCircleParam1); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam2", kExternallyStrobedBestCircleParam2); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleHoughDpParam1", kExternallyStrobedBestCircleHoughDpParam1); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedUseCLAHEProcessing", kExternallyStrobedUseCLAHEProcessing); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedCLAHEClipLimit", kExternallyStrobedCLAHEClipLimit); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedCLAHETilesGridSize", kExternallyStrobedCLAHETilesGridSize); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvHoughDpParam1", kExternallyStrobedEnvHoughDpParam1); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaximumSearchRadius", kExternallyStrobedEnvMaximumSearchRadius); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinimumSearchRadius", kExternallyStrobedEnvMinimumSearchRadius); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedPreHoughBlurSize", kPlacedPreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedPreCannyBlurSize", kPlacedPreCannyBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreHoughBlurSize", kStrobedBallsPreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingPreHoughBlurSize", kPuttingPreHoughBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallHoughDpParam1", kPlacedBallHoughDpParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsHoughDpParam1", kStrobedBallsHoughDpParam1); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseBestCircleRefinement", kUseBestCircleRefinement); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseBestCircleLargestCircle", kUseBestCircleLargestCircle); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleCannyLower", kBestCircleCannyLower); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleCannyUpper", kBestCircleCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCirclePreCannyBlurSize", kBestCirclePreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCirclePreHoughBlurSize", kBestCirclePreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleParam1", kBestCircleParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleParam2", kBestCircleParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleHoughDpParam1", kBestCircleHoughDpParam1); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleIdentificationMinRadiusRatio", kBestCircleIdentificationMinRadiusRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleIdentificationMaxRadiusRatio", kBestCircleIdentificationMaxRadiusRatio); - - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseDynamicRadiiAdjustment", kUseDynamicRadiiAdjustment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kNumberRadiiToAverageForDynamicAdjustment", kNumberRadiiToAverageForDynamicAdjustment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiMinRatio", kStrobedNarrowingRadiiMinRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiMaxRatio", kStrobedNarrowingRadiiMaxRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiDpParam", kStrobedNarrowingRadiiDpParam); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiParam2", kStrobedNarrowingRadiiParam2); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiMinRatio", kPlacedNarrowingRadiiMinRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiMaxRatio", kPlacedNarrowingRadiiMaxRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingStartingParam2", kPlacedNarrowingStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiDpParam", kPlacedNarrowingRadiiDpParam); - - int kImageTypeToProcessWithYOLOAsInt; - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kImageTypeToProcessWithYOLO", kImageTypeToProcessWithYOLOAsInt); - kImageTypeToProcessWithYOLO= static_cast(kImageTypeToProcessWithYOLOAsInt); - - - GolfSimConfiguration::SetConstant("gs_config.logging.kLogIntermediateSpinImagesToFile", kLogIntermediateSpinImagesToFile); - // Preload model at startup if using experimental detection for either ball placement or flight if (kStrobedBallDetectionMethod == "experimental" || kBallPlacementDetectionMethod == "experimental") { @@ -4381,6 +4247,136 @@ namespace golf_sim { kSpinDetectionMethod = "ml"; } + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesIncrement", kCoarseXRotationDegreesIncrement); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesStart", kCoarseXRotationDegreesStart); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesEnd", kCoarseXRotationDegreesEnd); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesIncrement", kCoarseYRotationDegreesIncrement); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesStart", kCoarseYRotationDegreesStart); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesEnd", kCoarseYRotationDegreesEnd); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesIncrement", kCoarseZRotationDegreesIncrement); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesStart", kCoarseZRotationDegreesStart); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesEnd", kCoarseZRotationDegreesEnd); + + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseSearchResolution", kCoarseSearchResolution); + + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kGaborMinWhitePercent", kGaborMinWhitePercent); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kGaborMaxWhitePercent", kGaborMaxWhitePercent); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCannyLower", kPlacedBallCannyLower); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCannyUpper", kPlacedBallCannyUpper); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallStartingParam2", kPlacedBallStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallMinParam2", kPlacedBallMinParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallMaxParam2", kPlacedBallMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCurrentParam1", kPlacedBallCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallParam2Increment", kPlacedBallParam2Increment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedMinHoughReturnCircles", kPlacedMinHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedMaxHoughReturnCircles", kPlacedMaxHoughReturnCircles); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCannyLower", kStrobedBallsCannyLower); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCannyUpper", kStrobedBallsCannyUpper); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreCannyBlurSize", kStrobedBallsPreCannyBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreHoughBlurSize", kStrobedBallsPreHoughBlurSize); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsStartingParam2", kStrobedBallsStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMinParam2", kStrobedBallsMinParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMaxParam2", kStrobedBallsMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCurrentParam1", kStrobedBallsCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsParam2Increment", kStrobedBallsParam2Increment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMinHoughReturnCircles", kStrobedBallsMinHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMaxHoughReturnCircles", kStrobedBallsMaxHoughReturnCircles); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsUseAltHoughAlgorithm", kStrobedBallsUseAltHoughAlgorithm); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCannyLower", kStrobedBallsAltCannyLower); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCannyUpper", kStrobedBallsAltCannyUpper); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltPreCannyBlurSize", kStrobedBallsAltPreCannyBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltPreHoughBlurSize", kStrobedBallsAltPreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltStartingParam2", kStrobedBallsAltStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltMinParam2", kStrobedBallsAltMinParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltMaxParam2", kStrobedBallsAltMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCurrentParam1", kStrobedBallsAltCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltHoughDpParam1", kStrobedBallsAltHoughDpParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltParam2Increment", kStrobedBallsAltParam2Increment); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseCLAHEProcessing", kUseCLAHEProcessing); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kCLAHEClipLimit", kCLAHEClipLimit); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kCLAHETilesGridSize", kCLAHETilesGridSize); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallStartingParam2", kPuttingBallStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallMinParam2", kPuttingBallMinParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallMaxParam2", kPuttingBallMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallCurrentParam1", kPuttingBallCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallParam2Increment", kPuttingBallParam2Increment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingMinHoughReturnCircles", kPuttingMinHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingMaxHoughReturnCircles", kPuttingMaxHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingHoughDpParam1", kPuttingHoughDpParam1); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCurrentParam1", kExternallyStrobedEnvCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaxParam2", kExternallyStrobedEnvMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvStartingParam2", kExternallyStrobedEnvStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingParam2", kExternallyStrobedEnvNarrowingParam2); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingDpParam", kExternallyStrobedEnvNarrowingDpParam); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvParam2Increment", kExternallyStrobedEnvParam2Increment); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinHoughReturnCircles", kExternallyStrobedEnvMinHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaxHoughReturnCircles", kExternallyStrobedEnvMaxHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize", kExternallyStrobedEnvPreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCirclePreHoughBlurSize", kExternallyStrobedBestCirclePreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam1", kExternallyStrobedBestCircleParam1); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam2", kExternallyStrobedBestCircleParam2); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleHoughDpParam1", kExternallyStrobedBestCircleHoughDpParam1); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedUseCLAHEProcessing", kExternallyStrobedUseCLAHEProcessing); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedCLAHEClipLimit", kExternallyStrobedCLAHEClipLimit); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedCLAHETilesGridSize", kExternallyStrobedCLAHETilesGridSize); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvHoughDpParam1", kExternallyStrobedEnvHoughDpParam1); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaximumSearchRadius", kExternallyStrobedEnvMaximumSearchRadius); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinimumSearchRadius", kExternallyStrobedEnvMinimumSearchRadius); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedPreHoughBlurSize", kPlacedPreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedPreCannyBlurSize", kPlacedPreCannyBlurSize); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingPreHoughBlurSize", kPuttingPreHoughBlurSize); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallHoughDpParam1", kPlacedBallHoughDpParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsHoughDpParam1", kStrobedBallsHoughDpParam1); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseBestCircleRefinement", kUseBestCircleRefinement); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseBestCircleLargestCircle", kUseBestCircleLargestCircle); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleCannyLower", kBestCircleCannyLower); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleCannyUpper", kBestCircleCannyUpper); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCirclePreCannyBlurSize", kBestCirclePreCannyBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCirclePreHoughBlurSize", kBestCirclePreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleParam1", kBestCircleParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleParam2", kBestCircleParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleHoughDpParam1", kBestCircleHoughDpParam1); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleIdentificationMinRadiusRatio", kBestCircleIdentificationMinRadiusRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleIdentificationMaxRadiusRatio", kBestCircleIdentificationMaxRadiusRatio); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseDynamicRadiiAdjustment", kUseDynamicRadiiAdjustment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kNumberRadiiToAverageForDynamicAdjustment", kNumberRadiiToAverageForDynamicAdjustment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiMinRatio", kStrobedNarrowingRadiiMinRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiMaxRatio", kStrobedNarrowingRadiiMaxRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiDpParam", kStrobedNarrowingRadiiDpParam); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiParam2", kStrobedNarrowingRadiiParam2); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiMinRatio", kPlacedNarrowingRadiiMinRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiMaxRatio", kPlacedNarrowingRadiiMaxRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingStartingParam2", kPlacedNarrowingStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiDpParam", kPlacedNarrowingRadiiDpParam); + + int kImageTypeToProcessWithYOLOAsInt = static_cast(kImageTypeToProcessWithYOLO); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kImageTypeToProcessWithYOLO", kImageTypeToProcessWithYOLOAsInt); + kImageTypeToProcessWithYOLO = static_cast(kImageTypeToProcessWithYOLOAsInt); + + GolfSimConfiguration::SetConstant("gs_config.logging.kLogIntermediateSpinImagesToFile", kLogIntermediateSpinImagesToFile); + GS_LOG_MSG(info, "Model directory: " + kModelPath); GS_LOG_MSG(info, "Detection method: " + kStrobedBallDetectionMethod); GS_LOG_MSG(info, "Spin detection: " + kSpinDetectionMethod); From faa7deb885dddea8d20aedb358564fad30f2300f Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 17 Aug 2026 20:58:56 -0400 Subject: [PATCH 096/156] name the comparison-mode canny keys after the cleanup pass they drive --- Software/LMSourceCode/ImageProcessing/gs_camera.cpp | 10 +++++----- Software/LMSourceCode/ImageProcessing/gs_camera.h | 4 ++-- Software/web-server/configurations.json | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp index 5d4f8586..06b4ed17 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp @@ -99,8 +99,8 @@ namespace golf_sim { int GolfSimCamera::kExternallyStrobedEnvFilterHsvUpperS = 255; int GolfSimCamera::kExternallyStrobedEnvFilterHsvLowerV = 114; int GolfSimCamera::kExternallyStrobedEnvFilterHsvUpperV = 255; - int GolfSimCamera::kExternallyStrobedEnvCannyLower = 156; - int GolfSimCamera::kExternallyStrobedEnvCannyUpper = 337; + int GolfSimCamera::kExternallyStrobedEnvCleanupCannyLower = 156; + int GolfSimCamera::kExternallyStrobedEnvCleanupCannyUpper = 337; int GolfSimCamera::kExternallyStrobedEnvCleanupPreHoughBlurSize = 13; // 6 for External Strobe, 9 for normal int GolfSimCamera::kExternallyStrobedEnvPreCannyBlurSize = 3; @@ -191,8 +191,8 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvFilterHsvUpperS", kExternallyStrobedEnvFilterHsvUpperS); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvFilterHsvLowerV", kExternallyStrobedEnvFilterHsvLowerV); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvFilterHsvUpperV", kExternallyStrobedEnvFilterHsvUpperV); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCannyLower", kExternallyStrobedEnvCannyLower); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCannyUpper", kExternallyStrobedEnvCannyUpper); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCleanupCannyLower", kExternallyStrobedEnvCleanupCannyLower); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCleanupCannyUpper", kExternallyStrobedEnvCleanupCannyUpper); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCleanupPreHoughBlurSize", kExternallyStrobedEnvCleanupPreHoughBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvLinesAngleLower", kExternallyStrobedEnvLinesAngleLower); @@ -3550,7 +3550,7 @@ namespace golf_sim { // Get a good picture of the edges of the balls. Will probably have way too many shaft lines cv::Mat cannyOutput_for_balls; - cv::Canny(image_gray, cannyOutput_for_balls, kExternallyStrobedEnvCannyLower, kExternallyStrobedEnvCannyUpper); + cv::Canny(image_gray, cannyOutput_for_balls, kExternallyStrobedEnvCleanupCannyLower, kExternallyStrobedEnvCleanupCannyUpper); LoggingTools::DebugShowImage("Initial cannyOutput", cannyOutput_for_balls); diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.h b/Software/LMSourceCode/ImageProcessing/gs_camera.h index 318167ed..01ff5ae3 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.h +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.h @@ -168,8 +168,8 @@ namespace golf_sim { static int kExternallyStrobedEnvFilterHsvUpperS; static int kExternallyStrobedEnvFilterHsvLowerV; static int kExternallyStrobedEnvFilterHsvUpperV; - static int kExternallyStrobedEnvCannyLower; - static int kExternallyStrobedEnvCannyUpper; + static int kExternallyStrobedEnvCleanupCannyLower; + static int kExternallyStrobedEnvCleanupCannyUpper; static int kExternallyStrobedEnvCleanupPreHoughBlurSize; static int kExternallyStrobedEnvPreCannyBlurSize; static int kExternallyStrobedEnvLinesAngleLower; diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index da04f743..c5013f64 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -3178,7 +3178,7 @@ "default": 70, "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedEnvCannyLower": { + "gs_config.testing.kExternallyStrobedEnvCleanupCannyLower": { "category": "testing", "subcategory": "advanced", "displayName": "Ext Strobe Cleanup Canny Lower", @@ -3187,7 +3187,7 @@ "default": 156, "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedEnvCannyUpper": { + "gs_config.testing.kExternallyStrobedEnvCleanupCannyUpper": { "category": "testing", "subcategory": "advanced", "displayName": "Ext Strobe Cleanup Canny Upper", From 509f61c5e84198f2788aee8fde46fd9d73a48bdf Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 19 Aug 2026 18:54:49 -0400 Subject: [PATCH 097/156] test that every schema key has a reader --- Software/web-server/tests/test_config_keys.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py index 8e9b637d..150905ec 100644 --- a/Software/web-server/tests/test_config_keys.py +++ b/Software/web-server/tests/test_config_keys.py @@ -20,6 +20,11 @@ "gs_config.testing.test_shots_to_inject", } +# Keys sim_manager.py builds from the simulator name +UNCHECKED_PREFIXES = ("simulators.",) +# In the schema with no reader since the web server rewrite +UNREAD = {"system.camera_role"} + DEFINITION = re.compile(r"^[ \t]*(?:static\s+)?([\w:<>]+(?:[ \t]+[\w:<>]+)?)[ \t]+(?:(\w+)::)?(\w+)\s*=\s*([^;]+);", re.M) INTEGER_TYPES = {"int", "long", "unsigned int", "uint"} SET_CONSTANT = re.compile(r'SetConstant\(\s*"([^"]+)"\s*,\s*([\w:]+)\s*\)') @@ -36,10 +41,13 @@ } -def test_every_key_the_cpp_reads_is_in_the_schema(): +def cpp_keys(): source = "\n".join(p.read_text(errors="replace") for p in sorted(CPP_DIR.glob("*.cpp"))) + return set(CALL_LITERAL.findall(source)) | set(KEY_LITERAL.findall(source)) - keys = set(CALL_LITERAL.findall(source)) | set(KEY_LITERAL.findall(source)) + +def test_every_key_the_cpp_reads_is_in_the_schema(): + keys = cpp_keys() assert len(keys) > 200 settings = json.loads(SCHEMA.read_text())["settings"] @@ -47,6 +55,26 @@ def test_every_key_the_cpp_reads_is_in_the_schema(): assert missing == [] +def test_every_schema_key_has_a_reader(): + web_source = "\n".join( + p.read_text(errors="replace") + for p in sorted(HERE.parent.rglob("*")) + if p.suffix in (".py", ".js", ".html") and not {"tests", "node_modules", "htmlcov"} & set(p.relative_to(HERE.parent).parts) + ) + read_by_cpp = cpp_keys() + + settings = json.loads(SCHEMA.read_text())["settings"] + unread = sorted( + key + for key in settings + if key not in read_by_cpp + and key not in web_source + and key not in UNREAD + and not key.startswith(UNCHECKED_PREFIXES) + ) + assert unread == [] + + def cpp_literal(text): """The value of a bool, number or string literal; None for anything else.""" text = text.strip() From 4725212005a2f0f404a50450f617fb45d04bad79 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 20 Aug 2026 23:55:55 -0400 Subject: [PATCH 098/156] drop the duplicate libcamera_jpeg source and the early image dir set main already does --- Software/LMSourceCode/ImageProcessing/logging_tools.cpp | 7 ------- Software/LMSourceCode/ImageProcessing/meson.build | 1 - 2 files changed, 8 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/logging_tools.cpp b/Software/LMSourceCode/ImageProcessing/logging_tools.cpp index 42424445..0ec4b614 100644 --- a/Software/LMSourceCode/ImageProcessing/logging_tools.cpp +++ b/Software/LMSourceCode/ImageProcessing/logging_tools.cpp @@ -120,13 +120,6 @@ namespace golf_sim { boost::log::add_common_attributes(); boost::log::core::get()->add_global_attribute("Scope", boost::log::attributes::named_scope()); - if (!GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_.empty()) { - kBaseImageLoggingDir = GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_; - } - else { - LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; - } - ApplyLogLevel(); /* log formatter: diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index 0eddde14..aec0fcf4 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -186,7 +186,6 @@ pitrac_lm_sources += ([ 'libcamera_jpeg.cpp', 'ball_watcher.cpp', 'ball_watcher_image_buffer.cpp', - 'libcamera_jpeg.cpp', 'ball_image_proc.cpp', 'ncnn_detector.cpp', 'spin_predictor.cpp', From 822e8be6319c090255bee53375b5f4bbec23439b Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 21 Aug 2026 16:36:43 -0400 Subject: [PATCH 099/156] drop the unread system.camera_role setting --- Software/web-server/configurations.json | 16 ---------------- Software/web-server/tests/test_config_keys.py | 3 --- 2 files changed, 19 deletions(-) diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index c5013f64..e1f1aaca 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -286,22 +286,6 @@ "default": "2", "requiresRestart": true }, - "system.camera_role": { - "category": "System", - "subcategory": "advanced", - "displayName": "Camera Role (Dual Pi Mode)", - "description": "Which camera this Pi controls in dual Pi mode", - "type": "select", - "options": { - "camera1": "Camera 1 (Primary)", - "camera2": "Camera 2 (Secondary)" - }, - "default": "camera1", - "requiresRestart": true, - "visibleWhen": { - "system.mode": "dual" - } - }, "gs_config.modes.kStartInPuttingMode": { "category": "System", "subcategory": "basic", diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py index 150905ec..019fabb2 100644 --- a/Software/web-server/tests/test_config_keys.py +++ b/Software/web-server/tests/test_config_keys.py @@ -22,8 +22,6 @@ # Keys sim_manager.py builds from the simulator name UNCHECKED_PREFIXES = ("simulators.",) -# In the schema with no reader since the web server rewrite -UNREAD = {"system.camera_role"} DEFINITION = re.compile(r"^[ \t]*(?:static\s+)?([\w:<>]+(?:[ \t]+[\w:<>]+)?)[ \t]+(?:(\w+)::)?(\w+)\s*=\s*([^;]+);", re.M) INTEGER_TYPES = {"int", "long", "unsigned int", "uint"} @@ -69,7 +67,6 @@ def test_every_schema_key_has_a_reader(): for key in settings if key not in read_by_cpp and key not in web_source - and key not in UNREAD and not key.startswith(UNCHECKED_PREFIXES) ) assert unread == [] From aa53320fc35ed23aa0d531398da2c5495d289961 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 21 Aug 2026 19:55:54 -0400 Subject: [PATCH 100/156] add a python gspro sim on a tcp base shared with ogs --- Software/web-server/configurations.json | 42 ++++ Software/web-server/sim_manager.py | 25 ++- Software/web-server/sims/gspro_sim.py | 128 ++++++++++++ Software/web-server/sims/ogs_sim.py | 133 ++----------- Software/web-server/sims/tcp_sim.py | 171 ++++++++++++++++ Software/web-server/tests/test_gspro_sim.py | 182 ++++++++++++++++++ Software/web-server/tests/test_sim_manager.py | 23 +++ 7 files changed, 579 insertions(+), 125 deletions(-) create mode 100644 Software/web-server/sims/gspro_sim.py create mode 100644 Software/web-server/sims/tcp_sim.py create mode 100644 Software/web-server/tests/test_gspro_sim.py diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index e1f1aaca..6865c7cb 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -736,6 +736,48 @@ "default": 5, "requiresRestart": false }, + "simulators.gspro.enabled": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "GSPro Enabled", + "description": "Show GSPro in the simulators drawer and allow connecting", + "type": "boolean", + "default": false, + "requiresRestart": false + }, + "simulators.gspro.auto_connect": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "GSPro Auto-Connect", + "description": "Open the GSPro connection automatically when the server starts", + "type": "boolean", + "default": false, + "requiresRestart": false + }, + "simulators.gspro.host": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "GSPro Host", + "description": "IP address of the computer running GSPro Connect (e.g., 192.168.1.100)", + "type": "ip_address", + "default": "", + "requiresRestart": false + }, + "simulators.gspro.port": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "GSPro Port", + "description": "GSPro Connect port", + "type": "integer", + "min": 1, + "max": 65535, + "default": 921, + "requiresRestart": false + }, "gs_config.ball_identification.kUseCLAHEProcessing": { "category": "Ball Detection", "subcategory": "basic", diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index 75962d76..5f35bfc2 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -6,6 +6,7 @@ from models import ShotData from sim_interface import SimInterface +from sims.gspro_sim import GSProSim from sims.ogs_sim import OGSSim logger = logging.getLogger(__name__) @@ -25,13 +26,25 @@ def __init__(self, config_manager, broadcast: Optional[BroadcastFn] = None) -> N config_manager.register_callback("simulators.", self._on_config_change) def build_sims(self) -> None: - self._sims = {} - if self.config_manager.get_config("simulators.ogs.enabled"): - sim = OGSSim( - host=self.config_manager.get_config("simulators.ogs.host") or "", - port=int(self.config_manager.get_config("simulators.ogs.port") or 3111), - keepalive_sec=int(self.config_manager.get_config("simulators.ogs.keepalive_sec") or 5), + get = self.config_manager.get_config + sims: List[SimInterface] = [] + if get("simulators.ogs.enabled"): + sims.append( + OGSSim( + host=get("simulators.ogs.host") or "", + port=int(get("simulators.ogs.port") or 3111), + keepalive_sec=int(get("simulators.ogs.keepalive_sec") or 5), + ) + ) + if get("simulators.gspro.enabled"): + sims.append( + GSProSim( + host=get("simulators.gspro.host") or "", + port=int(get("simulators.gspro.port") or 921), + ) ) + self._sims = {} + for sim in sims: sim.set_status_callback(self._broadcast_status) self._sims[sim.name] = sim diff --git a/Software/web-server/sims/gspro_sim.py b/Software/web-server/sims/gspro_sim.py new file mode 100644 index 00000000..04f541a3 --- /dev/null +++ b/Software/web-server/sims/gspro_sim.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +import logging +import math +from typing import Callable, Dict, Optional + +from models import ShotData +from sims.tcp_sim import TcpSim + +logger = logging.getLogger(__name__) + +_MAX_SPEED_MPH = 200.0 +_ZERO_CLUB_DATA = { + key: 0.0 + for key in ( + "Speed", + "AngleOfAttack", + "FaceToTarget", + "Lie", + "Loft", + "Path", + "SpeedAtImpact", + "VerticalFaceImpact", + "HorizontalFaceImpact", + "ClosureRate", + ) +} + + +def _round1(value: float) -> float: + # std::round in GsResults::FormatDoubleAsString rounds halves away from zero + return math.copysign(math.floor(abs(value) * 10 + 0.5), value) / 10 + + +def _spin_axis(back: float, side: float) -> float: + # Same formula as GsResults::GetSpinAxis, so a negative back spin keeps the C++ sign + if abs(side) <= 0.0001: + return 0.0 + if back == 0: + return math.copysign(90.0, side) + return math.degrees(math.atan(side / back + 0.00001)) + + +def _message(shot_number: int, ball: Dict[str, float], heartbeat: bool, ball_detected: bool) -> Dict[str, object]: + return { + "DeviceID": "PiTrac LM 0.1", + "Units": "Yards", + "ShotNumber": shot_number, + "APIversion": "1", + "BallData": {key: _round1(value) for key, value in ball.items()}, + "ClubData": _ZERO_CLUB_DATA, + "ShotDataOptions": { + "ContainsBallData": not heartbeat, + "ContainsClubData": False, + "LaunchMonitorIsReady": True, + "LaunchMonitorBallDetected": ball_detected, + "IsHeartBeat": heartbeat, + }, + } + + +def build_shot_payload(shot: ShotData, shot_number: int) -> Dict[str, object]: + back = float(shot.back_spin) + side = float(shot.side_spin) + ball = { + "Speed": min(float(shot.speed), _MAX_SPEED_MPH), + "SpinAxis": _spin_axis(back, side), + "TotalSpin": math.hypot(back, side), + "BackSpin": back, + "SideSpin": side, + "HLA": float(shot.side_angle), + "VLA": float(shot.launch_angle), + } + return _message(shot_number, ball, heartbeat=False, ball_detected=True) + + +def build_heartbeat(ball_detected: bool) -> Dict[str, object]: + ball = dict.fromkeys(("Speed", "SpinAxis", "TotalSpin", "BackSpin", "SideSpin", "HLA", "VLA"), 0.0) + return _message(0, ball, heartbeat=True, ball_detected=ball_detected) + + +class GSProSim(TcpSim): + name = "gspro" + display_name = "GSPro" + READS = True + + def __init__(self, host: str, port: int = 921, on_club: Optional[Callable[[str], None]] = None) -> None: + super().__init__(host, port) + self._on_club = on_club + self._shot_number = 0 + + def _encode(self, obj: Dict[str, object]) -> bytes: + # The layout boost's write_json gave the C++ sender + return (json.dumps(obj, indent=4) + "\n").encode("utf-8") + + async def _on_connected(self) -> None: + self._shot_number = 0 + await self._send_obj(build_heartbeat(False)) + + async def on_ball_state(self, ball_detected: bool) -> None: + if self._writer is not None: + await self._send_or_reconnect(build_heartbeat(ball_detected)) + + async def send_shot(self, shot: ShotData) -> None: + if self._writer is None: + raise ConnectionError("GSPro not connected") + self._shot_number += 1 + await self._send_or_reconnect(build_shot_payload(shot, self._shot_number)) + + async def _on_message(self, obj: object) -> None: + if not isinstance(obj, dict): + logger.warning(f"GSPro sent an unexpected message: {obj!r}") + return + code = obj.get("Code") + message = obj.get("Message", "") + if code == 201: + player = obj.get("Player") or {} + # GsGSProResponse treats any club but PT as the driver + club = "putter" if player.get("Club") == "PT" else "driver" + logger.info(f"GSPro player info: club {player.get('Club')!r}, handed {player.get('Handed')!r}") + if self._on_club is not None: + self._on_club(club) + elif code == 200: + logger.info(f"GSPro: {message}") + else: + logger.warning(f"GSPro returned {code}: {message}") + await self._set_status(self.status, f"{self.host}:{self.port} GSPro {code}: {message}") diff --git a/Software/web-server/sims/ogs_sim.py b/Software/web-server/sims/ogs_sim.py index c26acd42..66925523 100644 --- a/Software/web-server/sims/ogs_sim.py +++ b/Software/web-server/sims/ogs_sim.py @@ -2,22 +2,13 @@ import asyncio import json -import logging import math from typing import Dict, Optional from models import ShotData -from sim_interface import ( - SimInterface, - STATUS_CONNECTED, - STATUS_CONNECTING, - STATUS_ERROR, - STATUS_OFF, -) +from sims.tcp_sim import TcpSim -logger = logging.getLogger(__name__) - -_RECONNECT_BACKOFF_SEC = [1, 2, 5, 10] +_READY = {"type": "device", "status": "ready"} def _clamp(value: float, low: float, high: float) -> float: @@ -42,127 +33,31 @@ def build_shot_payload(shot: ShotData) -> Dict[str, object]: } -class OGSSim(SimInterface): +class OGSSim(TcpSim): name = "ogs" display_name = "OpenGolfSim" - CONNECT_TIMEOUT_SEC = 5 def __init__(self, host: str, port: int = 3111, keepalive_sec: int = 5) -> None: - super().__init__() - self.host = host - self.port = port + super().__init__(host, port) self.keepalive_sec = max(1, int(keepalive_sec)) - self._writer: Optional[asyncio.StreamWriter] = None - self._reader: Optional[asyncio.StreamReader] = None self._keepalive_task: Optional[asyncio.Task] = None - self._reconnect_task: Optional[asyncio.Task] = None - self._want_connected = False - self._conn_lock = asyncio.Lock() - - def info(self) -> Dict[str, str]: - data = super().info() - data["target"] = f"{self.host}:{self.port}" if self.host else "" - return data - async def connect(self) -> None: - self._want_connected = True - await self._open() - - async def _open(self) -> None: - async with self._conn_lock: - if self._writer is not None: - return - if not self.host: - await self._set_status(STATUS_ERROR, "no host configured") - return - await self._set_status(STATUS_CONNECTING, f"{self.host}:{self.port}") - try: - self._reader, self._writer = await asyncio.wait_for( - asyncio.open_connection(self.host, self.port), self.CONNECT_TIMEOUT_SEC - ) - await self._send_obj({"type": "device", "status": "ready"}) - except asyncio.CancelledError: - self._teardown_socket() - await self._set_status(STATUS_OFF, "") - raise - except Exception as e: - logger.warning(f"OGS connect failed: {e!r}") - await self._set_status(STATUS_ERROR, str(e) or type(e).__name__) - self._schedule_reconnect() - return - await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") - self._keepalive_task = asyncio.create_task(self._keepalive_loop()) + def _encode(self, obj: Dict[str, object]) -> bytes: + return (json.dumps(obj) + "\n").encode("utf-8") - async def _send_obj(self, obj: Dict[str, object]) -> None: - if self._writer is None: - raise ConnectionError("not connected") - self._writer.write((json.dumps(obj) + "\n").encode("utf-8")) - await self._writer.drain() + async def _on_connected(self) -> None: + await self._send_obj(_READY) + self._keepalive_task = self._spawn(self._keepalive_loop()) async def send_shot(self, shot: ShotData) -> None: if self._writer is None: raise ConnectionError("OGS not connected") - try: - await self._send_obj(build_shot_payload(shot)) - except Exception as e: - logger.warning(f"OGS send_shot failed: {e}") - await self._set_status(STATUS_ERROR, str(e)) - self._schedule_reconnect() - raise + await self._send_or_reconnect(build_shot_payload(shot)) async def _keepalive_loop(self) -> None: - try: - while self._want_connected and self._writer is not None: - await asyncio.sleep(self.keepalive_sec) - try: - await self._send_obj({"type": "device", "status": "ready"}) - except Exception as e: - logger.warning(f"OGS keepalive failed: {e}") - await self._set_status(STATUS_ERROR, str(e)) - self._schedule_reconnect() - return - except asyncio.CancelledError: - pass - - def _schedule_reconnect(self) -> None: - self._teardown_socket() - if not self._want_connected: - return - if self._reconnect_task and not self._reconnect_task.done(): - return - self._reconnect_task = asyncio.create_task(self._reconnect_loop()) - - async def _reconnect_loop(self) -> None: - attempt = 0 - while self._want_connected and self._writer is None: - delay = _RECONNECT_BACKOFF_SEC[min(attempt, len(_RECONNECT_BACKOFF_SEC) - 1)] - await asyncio.sleep(delay) - if not self._want_connected: - return - await self._open() - attempt += 1 - - def _teardown_socket(self) -> None: - if self._keepalive_task and not self._keepalive_task.done(): - self._keepalive_task.cancel() - self._keepalive_task = None - if self._writer is not None: + while self._want_connected and self._writer is not None: + await asyncio.sleep(self.keepalive_sec) try: - self._writer.close() + await self._send_or_reconnect(_READY) except Exception: - pass - self._writer = None - self._reader = None - - async def disconnect(self) -> None: - self._want_connected = False - if self._reconnect_task and not self._reconnect_task.done(): - self._reconnect_task.cancel() - writer = self._writer - self._teardown_socket() - if writer is not None: - try: - await writer.wait_closed() - except Exception: - pass - await self._set_status(STATUS_OFF, "") + return diff --git a/Software/web-server/sims/tcp_sim.py b/Software/web-server/sims/tcp_sim.py new file mode 100644 index 00000000..1efc9a56 --- /dev/null +++ b/Software/web-server/sims/tcp_sim.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Coroutine, Dict, List, Optional + +from sim_interface import ( + SimInterface, + STATUS_CONNECTED, + STATUS_CONNECTING, + STATUS_ERROR, + STATUS_OFF, +) + +logger = logging.getLogger(__name__) + +_RECONNECT_BACKOFF_SEC = [1, 2, 5, 10] + + +class TcpSim(SimInterface): + """A sim that is a TCP client of its simulator, with reconnect. + + Subclasses frame messages in _encode, send their greeting in _on_connected, + and set READS to have each JSON object the simulator sends passed to _on_message. + """ + + CONNECT_TIMEOUT_SEC = 5 + READS = False + + def __init__(self, host: str, port: int) -> None: + super().__init__() + self.host = host + self.port = port + self._writer: Optional[asyncio.StreamWriter] = None + self._reader: Optional[asyncio.StreamReader] = None + self._tasks: List[asyncio.Task] = [] + self._reconnect_task: Optional[asyncio.Task] = None + self._want_connected = False + self._conn_lock = asyncio.Lock() + + def info(self) -> Dict[str, str]: + data = super().info() + data["target"] = f"{self.host}:{self.port}" if self.host else "" + return data + + async def connect(self) -> None: + self._want_connected = True + await self._open() + + async def _open(self) -> None: + async with self._conn_lock: + if self._writer is not None: + return + if not self.host: + await self._set_status(STATUS_ERROR, "no host configured") + return + await self._set_status(STATUS_CONNECTING, f"{self.host}:{self.port}") + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), self.CONNECT_TIMEOUT_SEC + ) + await self._on_connected() + except asyncio.CancelledError: + self._teardown_socket() + await self._set_status(STATUS_OFF, "") + raise + except Exception as e: + logger.warning(f"{self.display_name} connect failed: {e!r}") + await self._set_status(STATUS_ERROR, str(e) or type(e).__name__) + self._schedule_reconnect() + return + await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") + if self.READS: + self._spawn(self._read_loop()) + + async def _on_connected(self) -> None: + pass + + def _encode(self, obj: Dict[str, object]) -> bytes: + return json.dumps(obj).encode("utf-8") + + async def _send_obj(self, obj: Dict[str, object]) -> None: + if self._writer is None: + raise ConnectionError("not connected") + self._writer.write(self._encode(obj)) + await self._writer.drain() + + async def _send_or_reconnect(self, obj: Dict[str, object]) -> None: + try: + await self._send_obj(obj) + except Exception as e: + logger.warning(f"{self.display_name} send failed: {e}") + await self._set_status(STATUS_ERROR, str(e)) + self._schedule_reconnect() + raise + + async def _on_message(self, obj: object) -> None: + pass + + async def _read_loop(self) -> None: + decoder = json.JSONDecoder() + buf = "" + try: + while True: + chunk = await self._reader.read(4096) + if not chunk: + break + buf += chunk.decode("utf-8", errors="replace") + while buf := buf.lstrip(): + try: + obj, end = decoder.raw_decode(buf) + except json.JSONDecodeError: + if buf[0] != "{": + logger.warning(f"{self.display_name} sent non-JSON, dropped: {buf[:200]!r}") + buf = "" + break + buf = buf[end:] + await self._on_message(obj) + except (ConnectionError, OSError) as e: + logger.warning(f"{self.display_name} read failed: {e}") + await self._set_status(STATUS_ERROR, "connection closed") + self._schedule_reconnect() + + def _spawn(self, coro: Coroutine) -> asyncio.Task: + task = asyncio.create_task(coro) + self._tasks.append(task) + return task + + def _schedule_reconnect(self) -> None: + self._teardown_socket() + if not self._want_connected: + return + if self._reconnect_task and not self._reconnect_task.done(): + return + self._reconnect_task = asyncio.create_task(self._reconnect_loop()) + + async def _reconnect_loop(self) -> None: + attempt = 0 + while self._want_connected and self._writer is None: + delay = _RECONNECT_BACKOFF_SEC[min(attempt, len(_RECONNECT_BACKOFF_SEC) - 1)] + await asyncio.sleep(delay) + if not self._want_connected: + return + await self._open() + attempt += 1 + + def _teardown_socket(self) -> None: + for task in self._tasks: + task.cancel() + self._tasks = [] + if self._writer is not None: + try: + self._writer.close() + except Exception: + pass + self._writer = None + self._reader = None + + async def disconnect(self) -> None: + self._want_connected = False + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + writer = self._writer + self._teardown_socket() + if writer is not None: + try: + await writer.wait_closed() + except Exception: + pass + await self._set_status(STATUS_OFF, "") diff --git a/Software/web-server/tests/test_gspro_sim.py b/Software/web-server/tests/test_gspro_sim.py new file mode 100644 index 00000000..4556442d --- /dev/null +++ b/Software/web-server/tests/test_gspro_sim.py @@ -0,0 +1,182 @@ +import asyncio +import json + +import pytest + +from models import ShotData +from sims.gspro_sim import GSProSim, build_heartbeat, build_shot_payload + +_ZERO_CLUB = { + "Speed": 0.0, + "AngleOfAttack": 0.0, + "FaceToTarget": 0.0, + "Lie": 0.0, + "Loft": 0.0, + "Path": 0.0, + "SpeedAtImpact": 0.0, + "VerticalFaceImpact": 0.0, + "HorizontalFaceImpact": 0.0, + "ClosureRate": 0.0, +} + + +def test_build_shot_payload_matches_the_cpp_object(): + shot = ShotData(speed=101.24, launch_angle=15.4, side_angle=-2.1, back_spin=3000, side_spin=-300) + assert build_shot_payload(shot, 7) == { + "DeviceID": "PiTrac LM 0.1", + "Units": "Yards", + "ShotNumber": 7, + "APIversion": "1", + "BallData": { + "Speed": 101.2, + "SpinAxis": -5.7, + "TotalSpin": 3015.0, + "BackSpin": 3000.0, + "SideSpin": -300.0, + "HLA": -2.1, + "VLA": 15.4, + }, + "ClubData": _ZERO_CLUB, + "ShotDataOptions": { + "ContainsBallData": True, + "ContainsClubData": False, + "LaunchMonitorIsReady": True, + "LaunchMonitorBallDetected": True, + "IsHeartBeat": False, + }, + } + assert build_shot_payload(ShotData(speed=250.0), 1)["BallData"]["Speed"] == 200.0 + assert build_shot_payload(ShotData(speed=101.25), 1)["BallData"]["Speed"] == 101.3 + + +def test_build_heartbeat_flips_the_flags(): + hb = build_heartbeat(True) + assert hb["ShotNumber"] == 0 + assert hb["BallData"]["Speed"] == 0.0 + assert hb["ShotDataOptions"] == { + "ContainsBallData": False, + "ContainsClubData": False, + "LaunchMonitorIsReady": True, + "LaunchMonitorBallDetected": True, + "IsHeartBeat": True, + } + assert build_heartbeat(False)["ShotDataOptions"]["LaunchMonitorBallDetected"] is False + + +class _FakeGSPro: + """Asyncio TCP server that records the raw concatenated JSON objects it receives.""" + + def __init__(self, on_connect=b'{"Code":201,"Message":"Player Info","Player":{"Handed":"RH","Club":"DR"}}'): + self.messages = [] + self.on_connect = on_connect + self.writers = [] + self._server = None + self.port = None + + async def start(self): + self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0) + self.port = self._server.sockets[0].getsockname()[1] + + async def _handle(self, reader, writer): + self.writers.append(writer) + writer.write(self.on_connect) + decoder = json.JSONDecoder() + buf = "" + while True: + chunk = await reader.read(4096) + if not chunk: + break + buf += chunk.decode() + while buf: + obj, end = decoder.raw_decode(buf) + buf = buf[end:].lstrip() + self.messages.append(obj) + if not obj["ShotDataOptions"]["IsHeartBeat"]: + writer.write(b'{"Code":200,"Message":"Shot received successfully"}') + writer.close() + + async def stop(self): + self._server.close() + await self._server.wait_closed() + + +@pytest.fixture(autouse=True) +def _enable_sockets_if_plugin_present(): + try: + import pytest_socket + + pytest_socket.enable_socket() + except ImportError: + pass + + +@pytest.mark.asyncio +async def test_connect_sends_heartbeat_then_numbered_shots(): + fake = _FakeGSPro() + await fake.start() + sim = GSProSim(host="127.0.0.1", port=fake.port) + await sim.connect() + await sim.send_shot(ShotData(speed=99.0, back_spin=2500)) + await sim.send_shot(ShotData(speed=98.0, back_spin=2400)) + await asyncio.sleep(0.05) + await sim.disconnect() + await fake.stop() + + assert [m["ShotDataOptions"]["IsHeartBeat"] for m in fake.messages] == [True, False, False] + assert [m["ShotNumber"] for m in fake.messages[1:]] == [1, 2] + + +@pytest.mark.asyncio +async def test_on_ball_state_sends_a_heartbeat(): + fake = _FakeGSPro() + await fake.start() + sim = GSProSim(host="127.0.0.1", port=fake.port) + await sim.connect() + await sim.on_ball_state(True) + await asyncio.sleep(0.05) + await sim.disconnect() + await fake.stop() + + assert fake.messages[-1]["ShotDataOptions"]["IsHeartBeat"] is True + assert fake.messages[-1]["ShotDataOptions"]["LaunchMonitorBallDetected"] is True + + +@pytest.mark.asyncio +async def test_two_responses_in_one_segment_both_parse(): + fake = _FakeGSPro(on_connect=b'{"Code":201,"Player":{"Club":"PT"}}{"Code":501,"Message":"bad shot"}') + await fake.start() + clubs = [] + sim = GSProSim(host="127.0.0.1", port=fake.port, on_club=clubs.append) + await sim.connect() + await asyncio.sleep(0.05) + + assert clubs == ["putter"] + assert "501" in sim.info()["detail"] + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_player_info_club_reaches_the_callback(): + fake = _FakeGSPro(on_connect=b'{"Code":201,"Player":{"Handed":"RH","Club":"PT"}}') + await fake.start() + clubs = [] + sim = GSProSim(host="127.0.0.1", port=fake.port, on_club=clubs.append) + await sim.connect() + await asyncio.sleep(0.05) + fake.writers[0].write(b'{"Code":201,"Player":{"Handed":"RH","Club":"DR"}}') + await asyncio.sleep(0.05) + await sim.disconnect() + await fake.stop() + + assert clubs == ["putter", "driver"] + + +@pytest.mark.asyncio +async def test_connect_failure_sets_error_and_schedules_reconnect(): + sim = GSProSim(host="127.0.0.1", port=1) + await sim.connect() + + assert sim.status == "error" + assert sim._reconnect_task is not None and not sim._reconnect_task.done() + await sim.disconnect() diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index 2e03daf1..9c699ea5 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -66,6 +66,29 @@ async def test_enabled_ogs_is_built(): assert names == ["ogs"] +def _gspro_values(enabled): + return { + "simulators.gspro.enabled": enabled, "simulators.gspro.auto_connect": False, + "simulators.gspro.host": "1.2.3.5", "simulators.gspro.port": 921, + } + + +@pytest.mark.asyncio +async def test_enabled_gspro_is_built(): + mgr = SimManager(_StubConfig(_gspro_values(True)), broadcast=None) + mgr.build_sims() + assert [s["name"] for s in mgr.status()] == ["gspro"] + + +@pytest.mark.asyncio +async def test_both_sims_are_built(): + cfg = _ogs_config(True) + cfg._v.update(_gspro_values(True)) + mgr = SimManager(cfg, broadcast=None) + mgr.build_sims() + assert [s["name"] for s in mgr.status()] == ["ogs", "gspro"] + + @pytest.mark.asyncio async def test_on_shot_fans_out_and_isolates_failures(): mgr = SimManager(_StubConfig({}), broadcast=None) From 0990f7ade9de2162ff76e776ea113edfddd4b81c Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 21 Aug 2026 22:13:29 -0400 Subject: [PATCH 101/156] harden the gspro reader and keep shot numbers across reconnects --- Software/web-server/sims/gspro_sim.py | 20 +++-- Software/web-server/sims/ogs_sim.py | 4 + Software/web-server/sims/tcp_sim.py | 10 ++- Software/web-server/tests/test_gspro_sim.py | 97 ++++++++++++++++++++- 4 files changed, 118 insertions(+), 13 deletions(-) diff --git a/Software/web-server/sims/gspro_sim.py b/Software/web-server/sims/gspro_sim.py index 04f541a3..ce09c8b3 100644 --- a/Software/web-server/sims/gspro_sim.py +++ b/Software/web-server/sims/gspro_sim.py @@ -1,5 +1,6 @@ from __future__ import annotations +import itertools import json import logging import math @@ -11,6 +12,8 @@ logger = logging.getLogger(__name__) _MAX_SPEED_MPH = 200.0 +# Process-wide like GsSimInterface::shot_counter_, so numbers survive reconnects and sim rebuilds +_shot_numbers = itertools.count(1) _ZERO_CLUB_DATA = { key: 0.0 for key in ( @@ -88,32 +91,37 @@ class GSProSim(TcpSim): def __init__(self, host: str, port: int = 921, on_club: Optional[Callable[[str], None]] = None) -> None: super().__init__(host, port) self._on_club = on_club - self._shot_number = 0 def _encode(self, obj: Dict[str, object]) -> bytes: # The layout boost's write_json gave the C++ sender return (json.dumps(obj, indent=4) + "\n").encode("utf-8") async def _on_connected(self) -> None: - self._shot_number = 0 await self._send_obj(build_heartbeat(False)) async def on_ball_state(self, ball_detected: bool) -> None: - if self._writer is not None: + if self._writer is None: + return + try: await self._send_or_reconnect(build_heartbeat(ball_detected)) + except Exception: + pass async def send_shot(self, shot: ShotData) -> None: if self._writer is None: raise ConnectionError("GSPro not connected") - self._shot_number += 1 - await self._send_or_reconnect(build_shot_payload(shot, self._shot_number)) + await self._send_or_reconnect(build_shot_payload(shot, next(_shot_numbers))) async def _on_message(self, obj: object) -> None: if not isinstance(obj, dict): logger.warning(f"GSPro sent an unexpected message: {obj!r}") return - code = obj.get("Code") message = obj.get("Message", "") + try: + code = int(obj.get("Code", 0)) + except (TypeError, ValueError): + logger.warning(f"GSPro sent a message with no usable Code: {obj!r}") + return if code == 201: player = obj.get("Player") or {} # GsGSProResponse treats any club but PT as the driver diff --git a/Software/web-server/sims/ogs_sim.py b/Software/web-server/sims/ogs_sim.py index 66925523..ca807531 100644 --- a/Software/web-server/sims/ogs_sim.py +++ b/Software/web-server/sims/ogs_sim.py @@ -42,6 +42,10 @@ def __init__(self, host: str, port: int = 3111, keepalive_sec: int = 5) -> None: self.keepalive_sec = max(1, int(keepalive_sec)) self._keepalive_task: Optional[asyncio.Task] = None + def _teardown_socket(self) -> None: + super()._teardown_socket() + self._keepalive_task = None + def _encode(self, obj: Dict[str, object]) -> bytes: return (json.dumps(obj) + "\n").encode("utf-8") diff --git a/Software/web-server/sims/tcp_sim.py b/Software/web-server/sims/tcp_sim.py index 1efc9a56..5b3534b7 100644 --- a/Software/web-server/sims/tcp_sim.py +++ b/Software/web-server/sims/tcp_sim.py @@ -16,6 +16,7 @@ logger = logging.getLogger(__name__) _RECONNECT_BACKOFF_SEC = [1, 2, 5, 10] +_MAX_UNPARSED_CHARS = 64 * 1024 class TcpSim(SimInterface): @@ -111,12 +112,15 @@ async def _read_loop(self) -> None: try: obj, end = decoder.raw_decode(buf) except json.JSONDecodeError: - if buf[0] != "{": - logger.warning(f"{self.display_name} sent non-JSON, dropped: {buf[:200]!r}") + if buf[0] != "{" or len(buf) > _MAX_UNPARSED_CHARS: + logger.warning(f"{self.display_name} sent unparseable data, dropped: {buf[:200]!r}") buf = "" break buf = buf[end:] - await self._on_message(obj) + try: + await self._on_message(obj) + except Exception as e: + logger.warning(f"{self.display_name} message handling failed: {e!r}") except (ConnectionError, OSError) as e: logger.warning(f"{self.display_name} read failed: {e}") await self._set_status(STATUS_ERROR, "connection closed") diff --git a/Software/web-server/tests/test_gspro_sim.py b/Software/web-server/tests/test_gspro_sim.py index 4556442d..02d55f98 100644 --- a/Software/web-server/tests/test_gspro_sim.py +++ b/Software/web-server/tests/test_gspro_sim.py @@ -1,5 +1,6 @@ import asyncio import json +from unittest.mock import MagicMock import pytest @@ -66,9 +67,12 @@ def test_build_heartbeat_flips_the_flags(): class _FakeGSPro: """Asyncio TCP server that records the raw concatenated JSON objects it receives.""" - def __init__(self, on_connect=b'{"Code":201,"Message":"Player Info","Player":{"Handed":"RH","Club":"DR"}}'): + def __init__( + self, on_connect=b'{"Code":201,"Message":"Player Info","Player":{"Handed":"RH","Club":"DR"}}', close=False + ): self.messages = [] self.on_connect = on_connect + self.close = close self.writers = [] self._server = None self.port = None @@ -80,6 +84,10 @@ async def start(self): async def _handle(self, reader, writer): self.writers.append(writer) writer.write(self.on_connect) + if self.close: + await writer.drain() + writer.close() + return decoder = json.JSONDecoder() buf = "" while True: @@ -111,7 +119,7 @@ def _enable_sockets_if_plugin_present(): @pytest.mark.asyncio -async def test_connect_sends_heartbeat_then_numbered_shots(): +async def test_connect_sends_heartbeat_then_shots(): fake = _FakeGSPro() await fake.start() sim = GSProSim(host="127.0.0.1", port=fake.port) @@ -123,7 +131,23 @@ async def test_connect_sends_heartbeat_then_numbered_shots(): await fake.stop() assert [m["ShotDataOptions"]["IsHeartBeat"] for m in fake.messages] == [True, False, False] - assert [m["ShotNumber"] for m in fake.messages[1:]] == [1, 2] + assert fake.messages[2]["ShotNumber"] == fake.messages[1]["ShotNumber"] + 1 + + +@pytest.mark.asyncio +async def test_shot_numbers_continue_across_reconnects_and_rebuilds(): + fake = _FakeGSPro() + await fake.start() + first = GSProSim(host="127.0.0.1", port=fake.port) + for sim in (first, first, GSProSim(host="127.0.0.1", port=fake.port)): + await sim.connect() + await sim.send_shot(ShotData(speed=99.0, back_spin=2500)) + await asyncio.sleep(0.05) + await sim.disconnect() + await fake.stop() + + numbers = [m["ShotNumber"] for m in fake.messages if not m["ShotDataOptions"]["IsHeartBeat"]] + assert numbers == [numbers[0], numbers[0] + 1, numbers[0] + 2] @pytest.mark.asyncio @@ -141,6 +165,19 @@ async def test_on_ball_state_sends_a_heartbeat(): assert fake.messages[-1]["ShotDataOptions"]["LaunchMonitorBallDetected"] is True +@pytest.mark.asyncio +async def test_failed_heartbeat_does_not_raise(): + async def broken_drain(): + raise ConnectionResetError("gone") + + sim = GSProSim(host="127.0.0.1", port=921) + sim._writer = MagicMock() + sim._writer.drain = broken_drain + await sim.on_ball_state(True) + + assert sim.status == "error" + + @pytest.mark.asyncio async def test_two_responses_in_one_segment_both_parse(): fake = _FakeGSPro(on_connect=b'{"Code":201,"Player":{"Club":"PT"}}{"Code":501,"Message":"bad shot"}') @@ -164,7 +201,7 @@ async def test_player_info_club_reaches_the_callback(): sim = GSProSim(host="127.0.0.1", port=fake.port, on_club=clubs.append) await sim.connect() await asyncio.sleep(0.05) - fake.writers[0].write(b'{"Code":201,"Player":{"Handed":"RH","Club":"DR"}}') + fake.writers[0].write(b'{"Code":"201","Player":{"Handed":"RH","Club":"DR"}}') await asyncio.sleep(0.05) await sim.disconnect() await fake.stop() @@ -180,3 +217,55 @@ async def test_connect_failure_sets_error_and_schedules_reconnect(): assert sim.status == "error" assert sim._reconnect_task is not None and not sim._reconnect_task.done() await sim.disconnect() + + +@pytest.mark.asyncio +async def test_oversized_unparseable_buffer_is_dropped(): + fake = _FakeGSPro(on_connect=b'{"Code": x' + b"y" * 70000) + await fake.start() + clubs = [] + sim = GSProSim(host="127.0.0.1", port=fake.port, on_club=clubs.append) + await sim.connect() + await asyncio.sleep(0.1) + fake.writers[0].write(b'{"Code":201,"Player":{"Club":"PT"}}') + await asyncio.sleep(0.05) + await sim.disconnect() + await fake.stop() + + assert clubs == ["putter"] + + +@pytest.mark.asyncio +async def test_failing_message_handler_keeps_the_reader_running(): + clubs = [] + + def on_club(club): + clubs.append(club) + if len(clubs) == 1: + raise RuntimeError("boom") + + fake = _FakeGSPro(on_connect=b'{"Code":201,"Player":{"Club":"PT"}}') + await fake.start() + sim = GSProSim(host="127.0.0.1", port=fake.port, on_club=on_club) + await sim.connect() + await asyncio.sleep(0.05) + fake.writers[0].write(b'{"Code":201,"Player":{"Club":"DR"}}') + await asyncio.sleep(0.05) + await sim.disconnect() + await fake.stop() + + assert clubs == ["putter", "driver"] + + +@pytest.mark.asyncio +async def test_peer_close_sets_error_and_schedules_reconnect(): + fake = _FakeGSPro(close=True) + await fake.start() + sim = GSProSim(host="127.0.0.1", port=fake.port) + await sim.connect() + await asyncio.sleep(0.05) + + assert sim.status == "error" + assert sim._reconnect_task is not None and not sim._reconnect_task.done() + await sim.disconnect() + await fake.stop() From 7b07d4d78034bcf0cd9bafb4bb3c431bb1b9f3ea Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 21 Aug 2026 22:19:50 -0400 Subject: [PATCH 102/156] send ball state, club and armed through the shot-result reply --- Software/web-server/constants.py | 2 +- Software/web-server/server.py | 33 +++++++--- Software/web-server/sim_interface.py | 3 + Software/web-server/sim_manager.py | 47 +++++++++++++- .../web-server/tests/test_shot_persistence.py | 2 +- Software/web-server/tests/test_sim_manager.py | 64 +++++++++++++++++++ Software/web-server/tests/test_sims_api.py | 25 +++++++- 7 files changed, 162 insertions(+), 14 deletions(-) diff --git a/Software/web-server/constants.py b/Software/web-server/constants.py index 0a539962..bb13229e 100644 --- a/Software/web-server/constants.py +++ b/Software/web-server/constants.py @@ -3,7 +3,7 @@ import os from pathlib import Path -MPS_TO_MPH = 2.237 +MPS_TO_MPH = 2.23694 HOME_DIR = Path(os.environ.get("HOME", str(Path.home()))) PITRAC_DIR = HOME_DIR / ".pitrac" diff --git a/Software/web-server/server.py b/Software/web-server/server.py index c29bbfff..a2878ac5 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -5,6 +5,7 @@ import shutil import subprocess from contextlib import asynccontextmanager +from dataclasses import replace from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Union @@ -314,7 +315,7 @@ async def websocket_sims(websocket: WebSocket) -> None: self.sim_connection_manager.disconnect(websocket) @self.app.post("/api/internal/shot-result") - async def receive_shot_result(request: Request) -> Dict[str, str]: + async def receive_shot_result(request: Request) -> Dict[str, Any]: """Receives shot results from the C++ pitrac_lm process via HTTP POST.""" body = await request.json() @@ -324,9 +325,8 @@ async def receive_shot_result(request: Request) -> Dict[str, str]: message = str(body.get("message", "")) is_status = result_type_str in self.parser._get_status_message_strings() - is_fake_hit = result_type_int == 7 and message in [ - "Club type was set", "Test message", "Configuration update", - ] + is_fake_hit = result_type_int == 7 and message.startswith("Club type was set") + sim_shot = None if is_status or is_fake_hit: current = self.shot_store.get() @@ -342,11 +342,14 @@ async def receive_shot_result(request: Request) -> Dict[str, str]: timestamp=datetime.now().isoformat(), ) else: + speed_mph = speed_mps * MPS_TO_MPH + launch_angle = float(body.get("launch_angle", 0)) + side_angle = float(body.get("side_angle", 0)) shot_data = ShotData( - speed=round(speed_mps * MPS_TO_MPH, 1), + speed=round(speed_mph, 1), carry=float(body.get("carry", 0)), - launch_angle=round(float(body.get("launch_angle", 0)), 1), - side_angle=round(float(body.get("side_angle", 0)), 1), + launch_angle=round(launch_angle, 1), + side_angle=round(side_angle, 1), back_spin=int(body.get("back_spin", 0)), side_spin=int(body.get("side_spin", 0)), result_type=result_type_str, @@ -355,6 +358,8 @@ async def receive_shot_result(request: Request) -> Dict[str, str]: ) shot_data.shot_id = body.get("shot_id") shot_data.images = list(body.get("images", [])) + # The sims round for their own protocol, so they get the values before display rounding + sim_shot = replace(shot_data, speed=speed_mph, launch_angle=launch_angle, side_angle=side_angle) if result_type_str == "Hit": shot_id = shot_data.shot_id or int(datetime.now().timestamp() * 1000) images = [(Path(p).stem, p) for p in shot_data.images] @@ -362,11 +367,19 @@ async def receive_shot_result(request: Request) -> Dict[str, str]: self.shot_store.update(shot_data) await self.connection_manager.broadcast(shot_data.to_dict()) - if not is_status and not is_fake_hit: - task = asyncio.create_task(self.sim_manager.on_shot(shot_data)) + sim_work = None + if is_status: + sim_work = self.sim_manager.on_status(result_type_str) + elif sim_shot is not None: + sim_work = self.sim_manager.on_shot(sim_shot) + if sim_work is not None: + task = asyncio.create_task(sim_work) self.background_tasks.add(task) task.add_done_callback(self.background_tasks.discard) - return {"status": "ok"} + reply: Dict[str, Any] = {"status": "ok", "armed": self.sim_manager.armed} + if self.sim_manager.club: + reply["club"] = self.sim_manager.club + return reply @self.app.post("/api/internal/image-ready") async def receive_image_ready(request: Request) -> Dict[str, str]: diff --git a/Software/web-server/sim_interface.py b/Software/web-server/sim_interface.py index 8b25b368..b2143aab 100644 --- a/Software/web-server/sim_interface.py +++ b/Software/web-server/sim_interface.py @@ -68,3 +68,6 @@ async def disconnect(self) -> None: @abstractmethod async def send_shot(self, shot: ShotData) -> None: ... + + async def on_ball_state(self, ball_detected: bool) -> None: + pass diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index 5f35bfc2..58c319f1 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import functools import logging from typing import Awaitable, Callable, Dict, List, Optional @@ -11,6 +12,13 @@ logger = logging.getLogger(__name__) +# The C++ sent a ball-detected heartbeat when it found the ball, just before the stabilization status +_BALL_DETECTED_BY_STATUS = { + "Waiting For Ball": False, + "Waiting For Placement To Stabilize": True, + "Ball Placed": True, +} + BroadcastFn = Callable[[Dict[str, object]], Awaitable[None]] @@ -23,6 +31,9 @@ def __init__(self, config_manager, broadcast: Optional[BroadcastFn] = None) -> N self._sims: Dict[str, SimInterface] = {} self.loop: Optional[asyncio.AbstractEventLoop] = None self._reload_task: Optional[asyncio.Task] = None + self._ball_detected: Optional[bool] = None + self._club: Optional[str] = None + self._club_sim: Optional[str] = None config_manager.register_callback("simulators.", self._on_config_change) def build_sims(self) -> None: @@ -41,11 +52,12 @@ def build_sims(self) -> None: GSProSim( host=get("simulators.gspro.host") or "", port=int(get("simulators.gspro.port") or 921), + on_club=functools.partial(self.set_club, "gspro"), ) ) self._sims = {} for sim in sims: - sim.set_status_callback(self._broadcast_status) + sim.set_status_callback(functools.partial(self._on_sim_status, sim)) self._sims[sim.name] = sim async def start(self) -> None: @@ -93,6 +105,33 @@ async def on_shot(self, shot: ShotData) -> None: except Exception as e: logger.warning(f"sim {name} send_shot failed: {e}") + async def on_status(self, result_type: str) -> None: + ball_detected = _BALL_DETECTED_BY_STATUS.get(result_type) + if ball_detected is None or ball_detected == self._ball_detected: + return + self._ball_detected = ball_detected + for name, sim in self._sims.items(): + if sim.status != "connected": + continue + try: + await sim.on_ball_state(ball_detected) + except Exception as e: + logger.warning(f"sim {name} on_ball_state failed: {e}") + + @property + def armed(self) -> bool: + return all( + sim.armed for sim in self._sims.values() if sim.status == "connected" and hasattr(sim, "armed") + ) + + @property + def club(self) -> Optional[str]: + return self._club + + def set_club(self, sim_name: str, club: str) -> None: + self._club = club + self._club_sim = sim_name + async def connect(self, name: str) -> None: sim = self._sims.get(name) if sim is None: @@ -108,6 +147,12 @@ async def disconnect(self, name: str) -> None: def status(self) -> List[Dict[str, str]]: return [sim.info() for sim in self._sims.values()] + async def _on_sim_status(self, sim: SimInterface) -> None: + if sim.name == self._club_sim and sim.status != "connected": + self._club = None + self._club_sim = None + await self._broadcast_status() + async def _broadcast_status(self) -> None: if self._broadcast is None: return diff --git a/Software/web-server/tests/test_shot_persistence.py b/Software/web-server/tests/test_shot_persistence.py index 113ef8e3..402cb0b2 100644 --- a/Software/web-server/tests/test_shot_persistence.py +++ b/Software/web-server/tests/test_shot_persistence.py @@ -29,7 +29,7 @@ def test_status_message_not_persisted(self, client, server_instance): def test_fake_hit_not_persisted(self, client, server_instance): client.post("/api/internal/shot-result", - json={"result_type": 7, "message": "Club type was set"}) + json={"result_type": 7, "message": "Club type was set to Putter"}) assert server_instance.session_repo.list() == [] def test_duplicate_post_is_idempotent(self, client, server_instance): diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index 9c699ea5..3601fa6c 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -22,6 +22,7 @@ def __init__(self): self.name = "stub" self.display_name = "Stub" self.shots = [] + self.ball_states = [] self.connected = False self._cb = None @@ -41,6 +42,9 @@ def status(self): async def send_shot(self, shot): self.shots.append(shot) + async def on_ball_state(self, ball_detected): + self.ball_states.append(ball_detected) + def info(self): return {"name": self.name, "status": self.status} @@ -111,6 +115,66 @@ async def send_shot(self, shot): assert skipped.shots == [] +@pytest.mark.asyncio +async def test_ball_state_changes_reach_sims_once(): + mgr = SimManager(_StubConfig({}), broadcast=None) + sim = _StubSim() + sim.connected = True + mgr._sims = {"stub": sim} + for status in ("Waiting For Ball", "Waiting For Ball", "Ball Placed", "Ball Placed"): + await mgr.on_status(status) + assert sim.ball_states == [False, True] + + +@pytest.mark.asyncio +async def test_on_status_isolates_failures(): + mgr = SimManager(_StubConfig({}), broadcast=None) + + class _Boom(_StubSim): + async def on_ball_state(self, ball_detected): + raise RuntimeError("dead sim") + + boom = _Boom() + boom.connected = True + good = _StubSim() + good.connected = True + mgr._sims = {"boom": boom, "good": good} + await mgr.on_status("Ball Placed") + assert good.ball_states == [True] + + +@pytest.mark.asyncio +async def test_armed_follows_sims_that_have_an_arm_state(): + cfg = _ogs_config(True) + cfg._v.update(_gspro_values(True)) + mgr = SimManager(cfg, broadcast=None) + assert mgr.armed is True + mgr.build_sims() + for sim in mgr._sims.values(): + sim._status = "connected" + assert mgr.armed is True + stub = _StubSim() + stub.connected = True + stub.armed = False + mgr._sims["stub"] = stub + assert mgr.armed is False + stub.armed = True + assert mgr.armed is True + + +@pytest.mark.asyncio +async def test_gspro_club_is_kept_until_it_disconnects(): + mgr = SimManager(_StubConfig(_gspro_values(True)), broadcast=None) + mgr.build_sims() + gspro = mgr._sims["gspro"] + gspro._status = "connected" + assert mgr.club is None + await gspro._on_message({"Code": 201, "Player": {"Club": "PT"}}) + assert mgr.club == "putter" + await mgr.disconnect("gspro") + assert mgr.club is None + + @pytest.mark.asyncio async def test_connect_disconnect_by_name(): mgr = SimManager(_StubConfig({}), broadcast=None) diff --git a/Software/web-server/tests/test_sims_api.py b/Software/web-server/tests/test_sims_api.py index fa8ff9db..d8fa81a9 100644 --- a/Software/web-server/tests/test_sims_api.py +++ b/Software/web-server/tests/test_sims_api.py @@ -1,5 +1,7 @@ import time +import pytest + from models import ShotData @@ -69,10 +71,31 @@ def test_status_or_fake_hit_is_not_forwarded_to_sims(client, server_instance): r = client.post("/api/internal/shot-result", json={ "result_type": 7, - "message": "Club type was set", + "message": "Club type was set to Putter", }) assert r.status_code == 200 # Give any erroneously-scheduled task a chance to run, then confirm none did. _wait_for(lambda: len(calls) > 0) assert calls == [] + + +def test_sims_get_unrounded_values(client, server_instance): + calls = _install_on_shot_spy(server_instance) + client.post("/api/internal/shot-result", json={ + "result_type": 7, "speed_mps": 65.0, "launch_angle": 12.25, "side_angle": -2.15, + }) + assert _wait_for(lambda: len(calls) == 1) + assert calls[0].speed == pytest.approx(65.0 * 2.23694) + assert (calls[0].launch_angle, calls[0].side_angle) == (12.25, -2.15) + stored = server_instance.shot_store.get() + assert (stored.speed, stored.launch_angle, stored.side_angle) == (145.4, 12.2, -2.1) + + +def test_status_reply_carries_armed_and_club(client, server_instance): + status = {"result_type": 2, "message": "Waiting for ball to be teed up."} + r = client.post("/api/internal/shot-result", json=status) + assert r.json() == {"status": "ok", "armed": True} + server_instance.sim_manager.set_club("gspro", "putter") + r = client.post("/api/internal/shot-result", json=status) + assert r.json() == {"status": "ok", "armed": True, "club": "putter"} From 109c5f9ba38cef1970036fcbd619267530ccbbd4 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 22 Aug 2026 11:36:30 -0400 Subject: [PATCH 103/156] read armed and club from the shot-result reply --- .../LMSourceCode/ImageProcessing/gs_clubs.cpp | 18 ++---- .../LMSourceCode/ImageProcessing/gs_fsm.cpp | 4 +- .../ImageProcessing/gs_http_client.cpp | 5 +- .../ImageProcessing/gs_http_client.h | 6 +- .../ImageProcessing/gs_ui_system.cpp | 59 ++++++++++++++++++- .../ImageProcessing/gs_ui_system.h | 10 ++++ Software/web-server/server.py | 3 +- .../web-server/tests/test_shot_persistence.py | 4 +- Software/web-server/tests/test_sims_api.py | 5 +- 9 files changed, 84 insertions(+), 30 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp b/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp index 6a393592..c65da784 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp @@ -6,8 +6,7 @@ #include "logging_tools.h" #include "gs_config.h" #include "gs_result_types.h" -#include "gs_http_client.h" -#include "gs_result_types.h" +#include "gs_ui_system.h" #include "gs_clubs.h" @@ -23,20 +22,11 @@ namespace golf_sim { void GolfSimClubs::SetCurrentClubType(GsClubType club_type) { current_club_ = club_type; - GS_LOG_MSG(info, "Club type set to " + std::string((club_type == GolfSimClubs::GsClubType::kPutter) ? "Putter" : "Driver")); - - // Notify the GUI, and possibly any attached Golf Sims about the change - // TBD - We need a new type of message. - // For now, just send a zero-results message with the - // new driver setting. + std::string club_name = (club_type == GolfSimClubs::GsClubType::kPutter) ? "Putter" : "Driver"; + GS_LOG_MSG(info, "Club type set to " + club_name); #ifdef __unix__ - std::string club_name = (club_type == GolfSimClubs::GsClubType::kPutter) ? "Putter" : "Driver"; - std::string json = "{\"result_type\":" + std::to_string(static_cast(GsIPCResultType::kHit)) - + ",\"message\":\"Club type was set to " + club_name + "\"" - + ",\"speed_mps\":0,\"launch_angle\":0,\"side_angle\":0" - + ",\"back_spin\":0,\"side_spin\":0,\"carry\":0,\"images\":[]}"; - GsHttpClient::PostResult(json); + GsUISystem::SendIPCStatusMessage(GsIPCResultType::kControlMessage, "Club type was set to " + club_name); #endif } diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index 2ceaa7eb..0f01de51 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -133,7 +133,7 @@ namespace golf_sim { GsUISystem::ResetCurrentShot(); // If we're already armed, just start waiting for a ball to appear. - if (GsSimInterface::GetAllSystemsArmed()) { + if (GsUISystem::SimArmed() && GsSimInterface::GetAllSystemsArmed()) { GolfSimEventElement beginWaitingForBallPlacedEvent{ new GolfSimEvent::BeginWaitingForBallPlaced{ } }; GolfSimEventQueue::QueueEvent(beginWaitingForBallPlacedEvent); @@ -348,7 +348,7 @@ namespace golf_sim { // Wait a moment so that we're not spinning too much sleep(1); - if (GsSimInterface::GetAllSystemsArmed()) { + if (GsUISystem::SimArmed() && GsSimInterface::GetAllSystemsArmed()) { GolfSimEventElement beginWaitingForBallPlacedEvent{ new GolfSimEvent::BeginWaitingForBallPlaced{ } }; GolfSimEventQueue::QueueEvent(beginWaitingForBallPlacedEvent); diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp index 76d35d9a..d949c420 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp @@ -42,7 +42,7 @@ std::string GsHttpClient::FetchConfig() { } } -void GsHttpClient::PostResult(const std::string& json_body) { +std::string GsHttpClient::PostResult(const std::string& json_body) { try { httplib::Client cli(host_, port_); cli.set_connection_timeout(1); @@ -54,10 +54,13 @@ void GsHttpClient::PostResult(const std::string& json_body) { GS_LOG_MSG(warning, "HTTP POST to web server failed (no response)"); } else if (res->status != 200) { GS_LOG_MSG(warning, "HTTP POST returned status " + std::to_string(res->status)); + } else { + return res->body; } } catch (const std::exception& e) { GS_LOG_MSG(warning, "HTTP POST exception: " + std::string(e.what())); } + return ""; } void GsHttpClient::PostImageReady(const std::string& filename) { diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.h b/Software/LMSourceCode/ImageProcessing/gs_http_client.h index 9d90571f..54772348 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.h +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.h @@ -12,13 +12,13 @@ namespace golf_sim { -// HTTP client for the Python web server. Shot result posts are fire-and-forget: -// failures are logged but don't block the shot cycle. +// HTTP client for the Python web server. Shot result post failures are logged +// but don't block the shot cycle. class GsHttpClient { public: static void Init(const std::string& host = "localhost", int port = 8080); static std::string FetchConfig(); // GET /api/internal/config; returns body or "" on failure - static void PostResult(const std::string& json_body); + static std::string PostResult(const std::string& json_body); // returns the reply body or "" on failure static void PostImageReady(const std::string& filename); static bool PutJson(const std::string& path, const std::string& json_body); static bool UpdateCalibration(const std::string& key, double value); diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index 3b6cd07c..a7a71d9e 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -10,6 +10,9 @@ #include #include #include +#include + +#include #include "logging_tools.h" @@ -20,6 +23,8 @@ #include "gs_sim_interface.h" #include "gs_camera.h" #include "gs_http_client.h" +#include "gs_events.h" +#include "gs_control_msg.h" #include "cv_utils.h" namespace golf_sim { @@ -35,6 +40,7 @@ namespace golf_sim { long GsUISystem::current_shot_id_ = 0; std::vector GsUISystem::current_shot_image_paths_; std::mutex GsUISystem::shot_images_mutex_; + std::atomic GsUISystem::sim_armed_{ true }; std::string GsUISystem::CurrentShotRelativePath(const std::string& file_name) { std::lock_guard lock(shot_images_mutex_); @@ -95,6 +101,50 @@ namespace golf_sim { return json; } + bool GsUISystem::SimArmed() { + return sim_armed_; + } + + void GsUISystem::PostResult(const std::string& json) { + std::string reply = GsHttpClient::PostResult(json); + + // A missing field, an unreadable reply or a server that is down all leave the FSM armed. + bool armed = true; + std::string club; + if (!reply.empty()) { + try { + boost::property_tree::ptree pt; + std::istringstream reply_stream(reply); + boost::property_tree::read_json(reply_stream, pt); + armed = pt.get("armed", "true") != "false"; + club = pt.get("club", ""); + } + catch (const std::exception& e) { + GS_LOG_MSG(warning, "Could not read the web server reply: " + std::string(e.what())); + } + } + sim_armed_ = armed; + + GolfSimClubs::GsClubType club_type; + GsIPCControlMsgType club_instruction; + if (club == "putter") { + club_type = GolfSimClubs::GsClubType::kPutter; + club_instruction = GsIPCControlMsgType::kClubChangeToPutter; + } + else if (club == "driver") { + club_type = GolfSimClubs::GsClubType::kDriver; + club_instruction = GsIPCControlMsgType::kClubChangeToDriver; + } + else { + return; + } + + if (club_type != GolfSimClubs::GetCurrentClubType()) { + GolfSimEventElement control_message{ new GolfSimEvent::ControlMessage{ club_instruction } }; + GolfSimEventQueue::QueueEvent(control_message); + } + } + void GsUISystem::SendIPCErrorStatusMessage(const std::string& error_message) { std::string msg; @@ -118,7 +168,7 @@ namespace golf_sim { } GS_LOG_TRACE_MSG(trace, "Sending error result: " + msg); - GsHttpClient::PostResult(BuildResultJson( + PostResult(BuildResultJson( static_cast(GsIPCResultType::kError), msg, 0, 0, 0, 0, 0, 0, images, shot_id)); } @@ -157,6 +207,9 @@ namespace golf_sim { case GsIPCResultType::kCalibrationResults: msg = "Returning Camera Calibration Results - see message."; break; + case GsIPCResultType::kControlMessage: + msg = "Control message."; + break; default: GS_LOG_TRACE_MSG(trace, "SendIPCStatusMessage received unknown GsIPCResultType : " + std::to_string((int)message_type)); return false; @@ -167,7 +220,7 @@ namespace golf_sim { } GS_LOG_TRACE_MSG(trace, "Sending status result: " + msg); - GsHttpClient::PostResult(BuildResultJson(static_cast(message_type), msg)); + PostResult(BuildResultJson(static_cast(message_type), msg)); return true; } @@ -200,7 +253,7 @@ namespace golf_sim { + std::to_string(side) + ", (Descent Angle-NA), (Apex-NA), (Flight Time-NA), (Type-NA)"); - GsHttpClient::PostResult(BuildResultJson( + PostResult(BuildResultJson( static_cast(GsIPCResultType::kHit), msg, speed, launch, side, back_spin, side_spin, carry, images, shot_id)); } diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h index 5766a2d8..b3e0ad90 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h @@ -10,6 +10,7 @@ #ifdef __unix__ // Ignore in Windows environment +#include #include #include #include @@ -56,6 +57,9 @@ namespace golf_sim { static void SendIPCHitMessage(const GolfBall& result_ball, const std::string& secondary_message = ""); + // Whether the web server's sims are ready for a shot, as of its last reply. + static bool SimArmed(); + // Save the image into the shared web-server directory so that the web-based // golf-sim user interface can access it. // Also save a uniquely-named copy to the usual images directory unless suppressed. @@ -64,6 +68,12 @@ namespace golf_sim { static bool SaveWebserverImage(const std::string& file_name, const cv::Mat& img, const std::vector& balls, bool suppress_diagnostic_saving = false); static void ClearWebserverImages(); + + private: + // Posts to the web server and applies the armed state and club in its reply. + static void PostResult(const std::string& json); + + static std::atomic sim_armed_; }; } diff --git a/Software/web-server/server.py b/Software/web-server/server.py index a2878ac5..d7aa44d0 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -325,10 +325,9 @@ async def receive_shot_result(request: Request) -> Dict[str, Any]: message = str(body.get("message", "")) is_status = result_type_str in self.parser._get_status_message_strings() - is_fake_hit = result_type_int == 7 and message.startswith("Club type was set") sim_shot = None - if is_status or is_fake_hit: + if is_status: current = self.shot_store.get() shot_data = ShotData( speed=current.speed, diff --git a/Software/web-server/tests/test_shot_persistence.py b/Software/web-server/tests/test_shot_persistence.py index 402cb0b2..376038b7 100644 --- a/Software/web-server/tests/test_shot_persistence.py +++ b/Software/web-server/tests/test_shot_persistence.py @@ -27,9 +27,9 @@ def test_status_message_not_persisted(self, client, server_instance): client.post("/api/internal/shot-result", json={"result_type": 2, "message": "waiting"}) assert server_instance.session_repo.list() == [] - def test_fake_hit_not_persisted(self, client, server_instance): + def test_club_change_not_persisted(self, client, server_instance): client.post("/api/internal/shot-result", - json={"result_type": 7, "message": "Club type was set to Putter"}) + json={"result_type": 10, "message": "Club type was set to Putter"}) assert server_instance.session_repo.list() == [] def test_duplicate_post_is_idempotent(self, client, server_instance): diff --git a/Software/web-server/tests/test_sims_api.py b/Software/web-server/tests/test_sims_api.py index d8fa81a9..e8ab5fd3 100644 --- a/Software/web-server/tests/test_sims_api.py +++ b/Software/web-server/tests/test_sims_api.py @@ -65,12 +65,11 @@ def test_real_shot_is_forwarded_to_sims(client, server_instance): assert isinstance(calls[0], ShotData) -def test_status_or_fake_hit_is_not_forwarded_to_sims(client, server_instance): - # result_type 7 with a fake-hit message -> must NOT be forwarded +def test_club_change_is_not_forwarded_to_sims(client, server_instance): calls = _install_on_shot_spy(server_instance) r = client.post("/api/internal/shot-result", json={ - "result_type": 7, + "result_type": 10, "message": "Club type was set to Putter", }) assert r.status_code == 200 From dfc67feda8634385223e446a2be901a5036820ab Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 22 Aug 2026 21:19:45 -0400 Subject: [PATCH 104/156] queue a club change from the reply only once --- Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp | 4 +++- Software/LMSourceCode/ImageProcessing/gs_ui_system.h | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index a7a71d9e..7c5ba8eb 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -41,6 +41,7 @@ namespace golf_sim { std::vector GsUISystem::current_shot_image_paths_; std::mutex GsUISystem::shot_images_mutex_; std::atomic GsUISystem::sim_armed_{ true }; + std::atomic GsUISystem::last_queued_club_{ GolfSimClubs::GsClubType::kNotSelected }; std::string GsUISystem::CurrentShotRelativePath(const std::string& file_name) { std::lock_guard lock(shot_images_mutex_); @@ -139,7 +140,8 @@ namespace golf_sim { return; } - if (club_type != GolfSimClubs::GetCurrentClubType()) { + // Queue a change once; modes without the FSM loop never drain the bounded event queue. + if (club_type != GolfSimClubs::GetCurrentClubType() && last_queued_club_.exchange(club_type) != club_type) { GolfSimEventElement control_message{ new GolfSimEvent::ControlMessage{ club_instruction } }; GolfSimEventQueue::QueueEvent(control_message); } diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h index b3e0ad90..689abc39 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h @@ -74,6 +74,7 @@ namespace golf_sim { static void PostResult(const std::string& json); static std::atomic sim_armed_; + static std::atomic last_queued_club_; }; } From 38af4665c86459c23031c754c4a3f2d495a2672a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 22 Aug 2026 21:40:08 -0400 Subject: [PATCH 105/156] wrap the e6 auth object in a shared library python can call --- .../ImageProcessing/e6_auth_shim.cpp | 107 ++++++++++++++++++ .../LMSourceCode/ImageProcessing/meson.build | 50 ++++---- Software/web-server/sims/e6_auth.py | 39 +++++++ Software/web-server/tests/test_e6_auth.py | 45 ++++++++ packaging/build.sh | 3 +- 5 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp create mode 100644 Software/web-server/sims/e6_auth.py create mode 100644 Software/web-server/tests/test_e6_auth.py diff --git a/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp b/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp new file mode 100644 index 00000000..33aee157 --- /dev/null +++ b/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp @@ -0,0 +1,107 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* + * Copyright (C) 2022-2025, Verdant Consultants, LLC. + */ + +// C entry point to GsE6Response for the web server's E6 sim (sims/e6_auth.py). +// The real headers for the symbols stubbed below pull in OpenCV, so they are +// declared here with the same signatures instead. + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "gs_e6_response.h" + +namespace golf_sim { + + // Only reached for Arm, Disarm and PlayerDataModified, which the web server + // handles itself and never passes in. + class GsSimInterface { + public: + enum GolfSimulatorType { kNone = 0, kGSPro = 1, kE6 = 2 }; + static GsSimInterface* GetSimInterfaceByType(GolfSimulatorType sim_type); + }; + + GsSimInterface* GsSimInterface::GetSimInterfaceByType(GolfSimulatorType) { + return nullptr; + } + + enum class GsIPCControlMsgType { kUnknown = 0, kClubChangeToPutter = 1, kClubChangeToDriver = 2 }; + + class GsIPCControlMsg { + public: + static std::string FormatControlMessageType(const GsIPCControlMsgType t); + }; + + std::string GsIPCControlMsg::FormatControlMessageType(const GsIPCControlMsgType) { + return ""; + } + + class GolfSimEventElement; + + class GolfSimEventQueue { + public: + static bool QueueEvent(GolfSimEventElement& event); + }; + + bool GolfSimEventQueue::QueueEvent(GolfSimEventElement&) { + return false; + } + + class GsResults { + public: + static std::string GenerateStringFromJsonTree(const boost::property_tree::ptree& root); + }; + + // Same as gs_results.cpp + std::string GsResults::GenerateStringFromJsonTree(const boost::property_tree::ptree& root) { + std::stringstream ss; + boost::property_tree::write_json(ss, root); + + std::regex reg("\\\"([+-]?[0-9]+\\.{0,1}[0-9]*)\\\""); + std::string result = std::regex_replace(ss.str(), reg, "$1"); + + boost::replace_all(result, "\"true\"", "true"); + boost::replace_all(result, "\"false\"", "false"); + boost::replace_all(result, "\"APIversion\": 1,", "\"APIversion\": \"1\","); + + return result; + } +} + +namespace { + // GsE6Response logs every message at trace, which would fill the web server's journal + const bool kLogFilterSet = [] { + boost::log::core::get()->set_filter(boost::log::trivial::severity >= boost::log::trivial::warning); + return true; + }(); +} + +// sims/e6_auth.py depends on these codes: reply length, 0 for no reply, +// -1 rejected, -2 reply plus terminator larger than cap. +extern "C" int e6_process(const char* json, char* out, size_t cap) { + std::string reply; + try { + golf_sim::GsE6Response response; + if (!response.ProcessJson(json, reply)) { + return -1; + } + } + catch (...) { + return -1; + } + if (reply.size() + 1 > cap) { + return -2; + } + std::memcpy(out, reply.c_str(), reply.size() + 1); + return static_cast(reply.size()); +} diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index aec0fcf4..629b9f3d 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -138,17 +138,16 @@ endif closed_source_object_files = [] pitrac_lm_sources = [] +e6_auth_sources = [ 'e6_auth_shim.cpp' ] +e6_auth_deps = [ boost_dep, ssl_dep, thread_dep ] if enable_recompile_closed_source message('Recompiling closed source code') run_command('cp', '/home/USERNAME/Dev/ClosedSourceCode/gs_e6_response.cpp.FULL', 'gs_e6_response.cpp', check:true) - pitrac_lm_sources += ([ 'gs_e6_response.cpp' ]) -endif - -if enable_recompile_closed_source - message('Recompiling closed source code') run_command('cp', '/home/USERNAME/Dev/ClosedSourceCode/obfuscate.h', 'obfuscate.h', check:true) pitrac_lm_sources += ([ 'gs_e6_response.cpp' ]) + e6_auth_sources += ([ 'gs_e6_response.cpp' ]) + e6_auth_deps += ([ opencv_dep.partial_dependency(compile_args : true, includes : true) ]) endif # Move the pre-compiled, closed-source object file(s) into place @@ -247,23 +246,34 @@ exec = executable('pitrac_lm', cpp_args : cpp_arguments ) +e6_auth_lib = shared_library('pitrac_e6', + e6_auth_sources, + install : true, + install_dir : '/usr/lib/pitrac', + objects : closed_source_object_files, + dependencies : e6_auth_deps, + cpp_args : cpp_arguments + ) + # Hacky two targets, because can't figure out how to execute more than one command # per target. TBD -custom_target('post_build1', - depends: exec, # Ensure the executable is built first - input: exec, - output: 'dummy_file1', # NOTE - command will execute in the build directory - command: [ 'cp', '-f', './pitrac_lm.p/gs_e6_response.cpp.o', '../ClosedSourceObjectFiles'], - build_by_default : true -) - -custom_target('post_build2', - depends: exec, # Ensure the executable is built first - input: exec, - output: 'dummy_file2', # NOTE - command will execute in the build directory - command: [ 'cp', '-f', './pitrac_lm.p/gs_e6_response.cpp.o', '..'], - build_by_default : true -) +if enable_recompile_closed_source + custom_target('post_build1', + depends: e6_auth_lib, # Ensure the library is built first + input: e6_auth_lib, + output: 'dummy_file1', # NOTE - command will execute in the build directory + command: [ 'cp', '-f', './libpitrac_e6.so.p/gs_e6_response.cpp.o', '../ClosedSourceObjectFiles'], + build_by_default : true + ) + + custom_target('post_build2', + depends: e6_auth_lib, # Ensure the library is built first + input: e6_auth_lib, + output: 'dummy_file2', # NOTE - command will execute in the build directory + command: [ 'cp', '-f', './libpitrac_e6.so.p/gs_e6_response.cpp.o', '..'], + build_by_default : true + ) +endif summary({ 'libav encoder' : enable_libav, diff --git a/Software/web-server/sims/e6_auth.py b/Software/web-server/sims/e6_auth.py new file mode 100644 index 00000000..b606afcc --- /dev/null +++ b/Software/web-server/sims/e6_auth.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import ctypes +from typing import Optional + +LIB_PATH = "/usr/lib/pitrac/libpitrac_e6.so" +_BUFFER_SIZE = 4096 + +_lib: Optional[ctypes.CDLL] = None + + +class E6AuthError(RuntimeError): + pass + + +class E6AuthUnavailable(E6AuthError): + pass + + +def _load() -> ctypes.CDLL: + global _lib + if _lib is None: + try: + lib = ctypes.CDLL(LIB_PATH) + except OSError as e: + raise E6AuthUnavailable(f"e6 auth library missing: {LIB_PATH}") from e + lib.e6_process.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t] + lib.e6_process.restype = ctypes.c_int + _lib = lib + return _lib + + +def challenge_response(text: str) -> str: + """Pass one raw E6 message to the closed-source responder and return its reply ("" for none).""" + buf = ctypes.create_string_buffer(_BUFFER_SIZE) + length = _load().e6_process(text.encode(), buf, _BUFFER_SIZE) + if length < 0: + raise E6AuthError(f"e6_process returned {length}") + return buf.value.decode() diff --git a/Software/web-server/tests/test_e6_auth.py b/Software/web-server/tests/test_e6_auth.py new file mode 100644 index 00000000..40330aeb --- /dev/null +++ b/Software/web-server/tests/test_e6_auth.py @@ -0,0 +1,45 @@ +import ctypes + +import pytest + +from sims import e6_auth + + +class _FakeLib: + def __init__(self, reply: bytes, result: int): + self.received = None + + def e6_process(text, buf, cap): + self.received = text + ctypes.memmove(buf, reply + b"\0", len(reply) + 1) + return result + + self.e6_process = e6_process + + +@pytest.fixture(autouse=True) +def _unloaded(monkeypatch): + monkeypatch.setattr(e6_auth, "_lib", None) + + +def _inject(monkeypatch, fake): + monkeypatch.setattr(e6_auth.ctypes, "CDLL", lambda path: fake) + + +def test_missing_library_raises_unavailable(monkeypatch, tmp_path): + monkeypatch.setattr(e6_auth, "LIB_PATH", str(tmp_path / "libpitrac_e6.so")) + with pytest.raises(e6_auth.E6AuthUnavailable): + e6_auth.challenge_response('{"Type":"Challenge","Challenge":"abc"}') + + +def test_returns_the_reply_from_the_buffer(monkeypatch): + fake = _FakeLib(b'{"Type":"Pong"}', 15) + _inject(monkeypatch, fake) + assert e6_auth.challenge_response('{"Type":"SimCommand","SubType":"Ping"}') == '{"Type":"Pong"}' + assert fake.received == b'{"Type":"SimCommand","SubType":"Ping"}' + + +def test_negative_return_raises(monkeypatch): + _inject(monkeypatch, _FakeLib(b"", -1)) + with pytest.raises(e6_auth.E6AuthError): + e6_auth.challenge_response('{"Type":"Challenge"}') diff --git a/packaging/build.sh b/packaging/build.sh index e89f62e2..8ec138d5 100755 --- a/packaging/build.sh +++ b/packaging/build.sh @@ -484,7 +484,7 @@ build_dev() { fi log_info "Building with Ninja..." - ninja -C build pitrac_lm + ninja -C build pitrac_lm libpitrac_e6.so # Check if build succeeded if [[ ! -f "build/pitrac_lm" ]]; then @@ -495,6 +495,7 @@ build_dev() { # Install binary log_info "Installing PiTrac binary..." install -m 755 build/pitrac_lm /usr/lib/pitrac/pitrac_lm + install -m 644 build/libpitrac_e6.so /usr/lib/pitrac/libpitrac_e6.so # Remove any stale file capabilities — file capabilities trigger AT_SECURE # which breaks libcamera's secure_getenv() config file reading. From 136d51be31f41cf439e31056748a0f3422e0f407 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 22 Aug 2026 21:59:18 -0400 Subject: [PATCH 106/156] track the latest club from any source for the reply guard --- Software/LMSourceCode/ImageProcessing/gs_clubs.cpp | 2 ++ Software/LMSourceCode/ImageProcessing/gs_clubs.h | 5 +++++ Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp | 3 +-- Software/LMSourceCode/ImageProcessing/gs_ui_system.h | 1 - 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp b/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp index c65da784..7217a194 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp @@ -13,6 +13,7 @@ namespace golf_sim { GolfSimClubs::GsClubType GolfSimClubs::current_club_ = GolfSimClubs::kNotSelected; + std::atomic GolfSimClubs::latest_club_{ GolfSimClubs::kNotSelected }; GolfSimClubs::GsClubType GolfSimClubs::GetCurrentClubType() { @@ -21,6 +22,7 @@ namespace golf_sim { void GolfSimClubs::SetCurrentClubType(GsClubType club_type) { current_club_ = club_type; + latest_club_ = club_type; std::string club_name = (club_type == GolfSimClubs::GsClubType::kPutter) ? "Putter" : "Driver"; GS_LOG_MSG(info, "Club type set to " + club_name); diff --git a/Software/LMSourceCode/ImageProcessing/gs_clubs.h b/Software/LMSourceCode/ImageProcessing/gs_clubs.h index 1ac6c6ff..14ac38c6 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_clubs.h +++ b/Software/LMSourceCode/ImageProcessing/gs_clubs.h @@ -5,6 +5,8 @@ #pragma once +#include + // TThis is basically just a state flag for now. It's used // to determine whether the system is putting or not. @@ -23,6 +25,9 @@ namespace golf_sim { static GsClubType current_club_; + // The club most recently applied or queued to be applied, from any source. + static std::atomic latest_club_; + static GsClubType GetCurrentClubType(); static void SetCurrentClubType(GsClubType club_type); diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index 7c5ba8eb..cbe00b3d 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -41,7 +41,6 @@ namespace golf_sim { std::vector GsUISystem::current_shot_image_paths_; std::mutex GsUISystem::shot_images_mutex_; std::atomic GsUISystem::sim_armed_{ true }; - std::atomic GsUISystem::last_queued_club_{ GolfSimClubs::GsClubType::kNotSelected }; std::string GsUISystem::CurrentShotRelativePath(const std::string& file_name) { std::lock_guard lock(shot_images_mutex_); @@ -141,7 +140,7 @@ namespace golf_sim { } // Queue a change once; modes without the FSM loop never drain the bounded event queue. - if (club_type != GolfSimClubs::GetCurrentClubType() && last_queued_club_.exchange(club_type) != club_type) { + if (club_type != GolfSimClubs::GetCurrentClubType() && GolfSimClubs::latest_club_.exchange(club_type) != club_type) { GolfSimEventElement control_message{ new GolfSimEvent::ControlMessage{ club_instruction } }; GolfSimEventQueue::QueueEvent(control_message); } diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h index 689abc39..b3e0ad90 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h @@ -74,7 +74,6 @@ namespace golf_sim { static void PostResult(const std::string& json); static std::atomic sim_armed_; - static std::atomic last_queued_club_; }; } From 90d10a189fb86ee4840b3dbe6efc5394ea3e80cb Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 22 Aug 2026 23:42:21 -0400 Subject: [PATCH 107/156] link the e6 library against the full boost dep --- Software/LMSourceCode/ImageProcessing/meson.build | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index 629b9f3d..35373f78 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -96,6 +96,9 @@ pitrac_lm_module_deps = [ libcamera_dep, thread_dep, opencv_dep, lgpio_dep, rpicam_app_dep, fmt_dep, boost_dep, ssl_dep, ncnn_dep, omp_dep,] +# Before subdir('core'), which rebinds boost_dep to program_options only +e6_auth_deps = [ boost_dep, ssl_dep, thread_dep ] + subdir('core') subdir('encoder') @@ -139,7 +142,6 @@ endif closed_source_object_files = [] pitrac_lm_sources = [] e6_auth_sources = [ 'e6_auth_shim.cpp' ] -e6_auth_deps = [ boost_dep, ssl_dep, thread_dep ] if enable_recompile_closed_source message('Recompiling closed source code') From 2d2afb80063a16b32b9a89f628047241a1f16b7e Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 24 Aug 2026 22:17:18 -0400 Subject: [PATCH 108/156] only pass auth challenges to the e6 object and strip the installed library --- .../ImageProcessing/e6_auth_shim.cpp | 16 ++++++- Software/web-server/sims/e6_auth.py | 27 +++++++++--- Software/web-server/tests/test_e6_auth.py | 42 ++++++++++++++++--- packaging/build.sh | 2 +- 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp b/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp index 33aee157..dc3ac04e 100644 --- a/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp +++ b/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp @@ -3,7 +3,8 @@ * Copyright (C) 2022-2025, Verdant Consultants, LLC. */ -// C entry point to GsE6Response for the web server's E6 sim (sims/e6_auth.py). +// C entry point that answers the E6 authentication challenge for the web +// server's E6 sim (sims/e6_auth.py). // The real headers for the symbols stubbed below pull in OpenCV, so they are // declared here with the same signatures instead. @@ -86,11 +87,24 @@ namespace { }(); } +// Only challenge messages reach GsE6Response: Arm and Disarm dereference the +// null GetSimInterfaceByType stub, which would kill the web server process. +static bool IsChallenge(const char* json) { + boost::property_tree::ptree pt; + std::istringstream ss(json); + boost::property_tree::read_json(ss, pt); + const std::string type = pt.get("Type", ""); + return (type == "Handshake" || type == "Challenge") && pt.count("Challenge") > 0; +} + // sims/e6_auth.py depends on these codes: reply length, 0 for no reply, // -1 rejected, -2 reply plus terminator larger than cap. extern "C" int e6_process(const char* json, char* out, size_t cap) { std::string reply; try { + if (!IsChallenge(json)) { + return -1; + } golf_sim::GsE6Response response; if (!response.ProcessJson(json, reply)) { return -1; diff --git a/Software/web-server/sims/e6_auth.py b/Software/web-server/sims/e6_auth.py index b606afcc..c4d26de9 100644 --- a/Software/web-server/sims/e6_auth.py +++ b/Software/web-server/sims/e6_auth.py @@ -1,10 +1,12 @@ from __future__ import annotations import ctypes +import json from typing import Optional LIB_PATH = "/usr/lib/pitrac/libpitrac_e6.so" _BUFFER_SIZE = 4096 +_CHALLENGE_TYPES = ("Handshake", "Challenge") _lib: Optional[ctypes.CDLL] = None @@ -22,18 +24,33 @@ def _load() -> ctypes.CDLL: if _lib is None: try: lib = ctypes.CDLL(LIB_PATH) - except OSError as e: + process = lib.e6_process + except (OSError, AttributeError) as e: raise E6AuthUnavailable(f"e6 auth library missing: {LIB_PATH}") from e - lib.e6_process.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t] - lib.e6_process.restype = ctypes.c_int + process.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t] + process.restype = ctypes.c_int _lib = lib return _lib +def _is_challenge(text: str) -> bool: + try: + message = json.loads(text) + except ValueError: + return False + return isinstance(message, dict) and message.get("Type") in _CHALLENGE_TYPES and "Challenge" in message + + def challenge_response(text: str) -> str: - """Pass one raw E6 message to the closed-source responder and return its reply ("" for none).""" + """Answer an E6 authentication challenge (a Handshake or Challenge message carrying a Challenge field).""" + # Other messages reach code in the closed-source object that crashes this process + if not _is_challenge(text): + raise E6AuthError("not an e6 authentication challenge") buf = ctypes.create_string_buffer(_BUFFER_SIZE) length = _load().e6_process(text.encode(), buf, _BUFFER_SIZE) if length < 0: raise E6AuthError(f"e6_process returned {length}") - return buf.value.decode() + try: + return buf.value.decode() + except UnicodeDecodeError as e: + raise E6AuthError("e6_process reply is not utf-8") from e diff --git a/Software/web-server/tests/test_e6_auth.py b/Software/web-server/tests/test_e6_auth.py index 40330aeb..e1ffb8a0 100644 --- a/Software/web-server/tests/test_e6_auth.py +++ b/Software/web-server/tests/test_e6_auth.py @@ -26,20 +26,52 @@ def _inject(monkeypatch, fake): monkeypatch.setattr(e6_auth.ctypes, "CDLL", lambda path: fake) +_CHALLENGE = '{"Type":"Challenge","Challenge":"abc"}' + + def test_missing_library_raises_unavailable(monkeypatch, tmp_path): monkeypatch.setattr(e6_auth, "LIB_PATH", str(tmp_path / "libpitrac_e6.so")) with pytest.raises(e6_auth.E6AuthUnavailable): - e6_auth.challenge_response('{"Type":"Challenge","Challenge":"abc"}') + e6_auth.challenge_response(_CHALLENGE) + + +def test_library_without_entry_point_raises_unavailable(monkeypatch): + _inject(monkeypatch, object()) + with pytest.raises(e6_auth.E6AuthUnavailable): + e6_auth.challenge_response(_CHALLENGE) def test_returns_the_reply_from_the_buffer(monkeypatch): - fake = _FakeLib(b'{"Type":"Pong"}', 15) + fake = _FakeLib(b'{"Type":"Challenge","Hash":"h"}', 31) _inject(monkeypatch, fake) - assert e6_auth.challenge_response('{"Type":"SimCommand","SubType":"Ping"}') == '{"Type":"Pong"}' - assert fake.received == b'{"Type":"SimCommand","SubType":"Ping"}' + assert e6_auth.challenge_response(_CHALLENGE) == '{"Type":"Challenge","Hash":"h"}' + assert fake.received == _CHALLENGE.encode() + + +@pytest.mark.parametrize( + "text", + [ + '{"Type":"SimCommand","SubType":"Arm"}', + '{"Type":"SimCommand","SubType":"Ping"}', + '{"Type":"Handshake"}', + "not json", + ], +) +def test_non_challenge_never_reaches_the_library(monkeypatch, text): + fake = _FakeLib(b"", 0) + _inject(monkeypatch, fake) + with pytest.raises(e6_auth.E6AuthError): + e6_auth.challenge_response(text) + assert fake.received is None def test_negative_return_raises(monkeypatch): _inject(monkeypatch, _FakeLib(b"", -1)) with pytest.raises(e6_auth.E6AuthError): - e6_auth.challenge_response('{"Type":"Challenge"}') + e6_auth.challenge_response(_CHALLENGE) + + +def test_non_utf8_reply_raises(monkeypatch): + _inject(monkeypatch, _FakeLib(b"\xff\xfe", 2)) + with pytest.raises(e6_auth.E6AuthError): + e6_auth.challenge_response(_CHALLENGE) diff --git a/packaging/build.sh b/packaging/build.sh index 8ec138d5..511a852d 100755 --- a/packaging/build.sh +++ b/packaging/build.sh @@ -495,7 +495,7 @@ build_dev() { # Install binary log_info "Installing PiTrac binary..." install -m 755 build/pitrac_lm /usr/lib/pitrac/pitrac_lm - install -m 644 build/libpitrac_e6.so /usr/lib/pitrac/libpitrac_e6.so + install -s -m 644 build/libpitrac_e6.so /usr/lib/pitrac/libpitrac_e6.so # Remove any stale file capabilities — file capabilities trigger AT_SECURE # which breaks libcamera's secure_getenv() config file reading. From 427a7c77e66cddd12272ad9f04e62c3f19497e87 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 25 Aug 2026 18:54:18 -0400 Subject: [PATCH 109/156] add the e6 sim to the python sim layer --- Software/web-server/configurations.json | 53 ++++ Software/web-server/sim_manager.py | 10 + Software/web-server/sims/e6_sim.py | 133 ++++++++ Software/web-server/sims/tcp_sim.py | 10 +- Software/web-server/tests/test_e6_sim.py | 298 ++++++++++++++++++ Software/web-server/tests/test_sim_manager.py | 20 ++ 6 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 Software/web-server/sims/e6_sim.py create mode 100644 Software/web-server/tests/test_e6_sim.py diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 6865c7cb..1382b512 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -778,6 +778,59 @@ "default": 921, "requiresRestart": false }, + "simulators.e6.enabled": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "E6 Connect Enabled", + "description": "Show E6 Connect (TruGolf) in the simulators drawer and allow connecting", + "type": "boolean", + "default": false, + "requiresRestart": false + }, + "simulators.e6.auto_connect": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "E6 Connect Auto-Connect", + "description": "Open the E6 Connect connection automatically when the server starts", + "type": "boolean", + "default": false, + "requiresRestart": false + }, + "simulators.e6.host": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "E6 Connect Host", + "description": "IP address of the computer running E6 Connect (e.g., 192.168.1.100)", + "type": "ip_address", + "default": "", + "requiresRestart": false + }, + "simulators.e6.port": { + "category": "Simulators", + "subcategory": "basic", + "basicSubcategory": "Simulators", + "displayName": "E6 Connect Port", + "description": "E6 Connect TruSimAPI port", + "type": "integer", + "min": 1, + "max": 65535, + "default": 2483, + "requiresRestart": false + }, + "simulators.e6.inter_message_delay_ms": { + "category": "Simulators", + "subcategory": "advanced", + "displayName": "E6 Connect Inter-Message Delay (ms)", + "description": "Delay between the ball data, club data and send-shot messages of each shot", + "type": "integer", + "min": 0, + "max": 500, + "default": 50, + "requiresRestart": false + }, "gs_config.ball_identification.kUseCLAHEProcessing": { "category": "Ball Detection", "subcategory": "basic", diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index 58c319f1..f9653623 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -7,6 +7,7 @@ from models import ShotData from sim_interface import SimInterface +from sims.e6_sim import E6Sim from sims.gspro_sim import GSProSim from sims.ogs_sim import OGSSim @@ -55,6 +56,15 @@ def build_sims(self) -> None: on_club=functools.partial(self.set_club, "gspro"), ) ) + if get("simulators.e6.enabled"): + sims.append( + E6Sim( + host=get("simulators.e6.host") or "", + port=int(get("simulators.e6.port") or 2483), + inter_message_delay_ms=int(get("simulators.e6.inter_message_delay_ms") or 0), + on_club=functools.partial(self.set_club, "e6"), + ) + ) self._sims = {} for sim in sims: sim.set_status_callback(functools.partial(self._on_sim_status, sim)) diff --git a/Software/web-server/sims/e6_sim.py b/Software/web-server/sims/e6_sim.py new file mode 100644 index 00000000..5cd3f47c --- /dev/null +++ b/Software/web-server/sims/e6_sim.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Callable, Dict, Optional + +from models import ShotData +from sim_interface import STATUS_CONNECTED, STATUS_ERROR +from sims import e6_auth +from sims.gspro_sim import _round1 +from sims.ogs_sim import _clamp +from sims.tcp_sim import TcpSim + +logger = logging.getLogger(__name__) + +_CHALLENGE_TYPES = ("Handshake", "Challenge") +_ZERO_CLUB_DATA = dict.fromkeys(("ClubHeadSpeed", "ClubAngleFace", "ClubAnglePath", "ClubHeadSpeedMPH"), 0.0) + + +def build_ball_data(shot: ShotData) -> Dict[str, object]: + # The ranges E6 enforces, which GsE6Results::Format meant to apply + ball = { + "BackSpin": _clamp(float(shot.back_spin), -999, 19999), + "BallSpeed": _clamp(float(shot.speed), 0.09, 249.9), + "LaunchAngle": float(shot.launch_angle), + "LaunchDirection": float(shot.side_angle), + "SideSpin": _clamp(float(shot.side_spin), -5999, 5999), + } + return {"Type": "SetBallData", "BallData": {key: _round1(value) for key, value in ball.items()}} + + +class E6Sim(TcpSim): + name = "e6" + display_name = "E6 Connect" + READS = True + CONNECTED_ON_OPEN = False + + def __init__( + self, + host: str, + port: int = 2483, + inter_message_delay_ms: int = 50, + on_club: Optional[Callable[[str], None]] = None, + auth: Callable[[str], str] = e6_auth.challenge_response, + ) -> None: + super().__init__(host, port) + self.inter_message_delay_ms = max(0, int(inter_message_delay_ms)) + self.armed = False + self._on_club = on_club + self._auth = auth + + def _teardown_socket(self) -> None: + super()._teardown_socket() + self.armed = False + + async def _on_connected(self) -> None: + await self._send_obj({"Type": "Handshake"}) + + async def disconnect(self) -> None: + if self._writer is not None: + try: + await self._send_obj({"Type": "Disconnect"}) + except Exception as e: + logger.warning(f"E6 disconnect message failed: {e}") + await super().disconnect() + + async def _set_armed(self, armed: bool) -> None: + self.armed = armed + await self._set_status(self.status, f"{self.host}:{self.port}" + (" armed" if armed else "")) + + async def _give_up(self, detail: str) -> None: + # Reconnecting cannot fix authentication, so stay down until the user connects again + logger.warning(f"E6: {detail}") + self._want_connected = False + await self._set_status(STATUS_ERROR, detail) + self._teardown_socket() + + async def send_shot(self, shot: ShotData) -> None: + if not self.armed: + logger.warning("E6 shot not sent: E6 is not armed") + return + delay = self.inter_message_delay_ms / 1000 + await self._send_or_reconnect(build_ball_data(shot)) + await asyncio.sleep(delay) + await self._send_or_reconnect({"Type": "SetClubData", "ClubData": _ZERO_CLUB_DATA}) + await asyncio.sleep(delay) + await self._send_or_reconnect({"Type": "SendShot"}) + await self._set_armed(False) + + async def _on_message(self, obj: object) -> None: + if not isinstance(obj, dict): + logger.warning(f"E6 sent an unexpected message: {obj!r}") + return + msg_type = obj.get("Type") + if msg_type in _CHALLENGE_TYPES and "Challenge" in obj: + try: + reply = self._auth(json.dumps(obj)) + if reply: + await self._send_or_reconnect(json.loads(reply)) + except (e6_auth.E6AuthError, ValueError) as e: + await self._give_up(f"E6 authentication failed: {e}") + elif msg_type == "Authentication": + if obj.get("Success") in ("true", True): + logger.info("E6 authenticated") + await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") + else: + await self._give_up(f"E6 authentication failed: {obj.get('Message') or obj}") + elif msg_type == "SimCommand": + await self._on_command(obj.get("SubType"), obj.get("Details")) + elif msg_type == "ACK": + logger.debug(f"E6 ACK: {obj.get('SubType')}") + elif msg_type in ("Warning", "ShotError"): + logger.warning(f"E6 {msg_type}: {obj}") + else: + logger.info(f"E6 sent {obj}") + + async def _on_command(self, sub_type: object, details: object) -> None: + if sub_type == "Ping": + await self._send_or_reconnect({"Type": "Pong"}) + elif sub_type == "Arm": + await self._set_armed(True) + elif sub_type == "Disarm": + await self._set_armed(False) + elif sub_type == "PlayerDataModified": + club_type = details.get("ClubType") if isinstance(details, dict) else None + logger.info(f"E6 player data: club {club_type!r}") + if club_type is not None and self._on_club is not None: + self._on_club("putter" if club_type == "Putter" else "driver") + elif sub_type == "ShotComplete": + logger.info(f"E6 shot complete: {details}") + else: + logger.info(f"E6 sim command {sub_type!r}: {details}") diff --git a/Software/web-server/sims/tcp_sim.py b/Software/web-server/sims/tcp_sim.py index 5b3534b7..14e0146e 100644 --- a/Software/web-server/sims/tcp_sim.py +++ b/Software/web-server/sims/tcp_sim.py @@ -24,10 +24,12 @@ class TcpSim(SimInterface): Subclasses frame messages in _encode, send their greeting in _on_connected, and set READS to have each JSON object the simulator sends passed to _on_message. + One that must authenticate first sets CONNECTED_ON_OPEN False and reports connected itself. """ CONNECT_TIMEOUT_SEC = 5 READS = False + CONNECTED_ON_OPEN = True def __init__(self, host: str, port: int) -> None: super().__init__() @@ -71,7 +73,8 @@ async def _open(self) -> None: await self._set_status(STATUS_ERROR, str(e) or type(e).__name__) self._schedule_reconnect() return - await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") + if self.CONNECTED_ON_OPEN: + await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") if self.READS: self._spawn(self._read_loop()) @@ -101,10 +104,11 @@ async def _on_message(self, obj: object) -> None: async def _read_loop(self) -> None: decoder = json.JSONDecoder() + reader = self._reader buf = "" try: while True: - chunk = await self._reader.read(4096) + chunk = await reader.read(4096) if not chunk: break buf += chunk.decode("utf-8", errors="replace") @@ -121,6 +125,8 @@ async def _read_loop(self) -> None: await self._on_message(obj) except Exception as e: logger.warning(f"{self.display_name} message handling failed: {e!r}") + if self._reader is not reader: + return except (ConnectionError, OSError) as e: logger.warning(f"{self.display_name} read failed: {e}") await self._set_status(STATUS_ERROR, "connection closed") diff --git a/Software/web-server/tests/test_e6_sim.py b/Software/web-server/tests/test_e6_sim.py new file mode 100644 index 00000000..b84806ee --- /dev/null +++ b/Software/web-server/tests/test_e6_sim.py @@ -0,0 +1,298 @@ +import asyncio +import json +from unittest.mock import MagicMock + +import pytest + +from models import ShotData +from sims.e6_auth import E6AuthUnavailable +from sims.e6_sim import E6Sim + +_ARM = b'{"Type":"SimCommand","SubType":"Arm"}' +_DISARM = b'{"Type":"SimCommand","SubType":"Disarm"}' +_PING = b'{"Type":"SimCommand","SubType":"Ping"}' + + +class _FakeE6: + """Asyncio TCP server playing E6 Connect: challenge on Handshake, authenticates any reply, ACKs everything.""" + + def __init__(self, auth_reply=b'{"Type":"Authentication","Success":"true"}'): + self.messages = [] + self.auth_reply = auth_reply + self.writers = [] + self._server = None + self.port = None + + async def start(self): + self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0) + self.port = self._server.sockets[0].getsockname()[1] + + async def _handle(self, reader, writer): + self.writers.append(writer) + decoder = json.JSONDecoder() + buf = "" + while True: + chunk = await reader.read(4096) + if not chunk: + break + buf += chunk.decode() + while buf := buf.lstrip(): + obj, end = decoder.raw_decode(buf) + buf = buf[end:] + self.messages.append(obj) + writer.write(json.dumps({"Type": "ACK", "SubType": obj["Type"], "Details": "Success."}).encode()) + if obj["Type"] == "Handshake": + writer.write(b'{"Type":"Handshake","Challenge":"abc123","E6Version":"2, 0, 0, 0"}') + elif obj["Type"] == "Challenge": + writer.write(self.auth_reply) + writer.close() + + def send(self, data): + self.writers[-1].write(data) + + def types(self): + return [m["Type"] for m in self.messages] + + async def stop(self): + self._server.close() + await self._server.wait_closed() + + +def _fake_auth(seen): + def answer(text): + seen.append(json.loads(text)) + return json.dumps({"Type": "Challenge", "Developer": "dev", "Hash": "hash-" + json.loads(text)["Challenge"]}) + + return answer + + +@pytest.fixture(autouse=True) +def _enable_sockets_if_plugin_present(): + try: + import pytest_socket + + pytest_socket.enable_socket() + except ImportError: + pass + + +async def _connected(fake, **kwargs): + kwargs.setdefault("auth", _fake_auth([])) + sim = E6Sim(host="127.0.0.1", port=fake.port, **kwargs) + await sim.connect() + await asyncio.sleep(0.05) + return sim + + +@pytest.mark.asyncio +async def test_handshake_is_answered_by_the_auth_function_then_connects(): + fake = _FakeE6() + await fake.start() + seen = [] + sim = E6Sim(host="127.0.0.1", port=fake.port, auth=_fake_auth(seen)) + await sim.connect() + assert sim.status == "connecting" + await asyncio.sleep(0.05) + + assert sim.status == "connected" + assert seen[0]["Challenge"] == "abc123" + assert fake.messages[:2] == [{"Type": "Handshake"}, {"Type": "Challenge", "Developer": "dev", "Hash": "hash-abc123"}] + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_failed_authentication_sets_error_and_does_not_arm(): + fake = _FakeE6(auth_reply=b'{"Type":"Authentication","Success":"false","Message":"bad developer"}' + _ARM) + await fake.start() + sim = await _connected(fake) + + assert sim.status == "error" + assert "bad developer" in sim.info()["detail"] + assert sim.armed is False + assert sim._reconnect_task is None + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_missing_auth_library_is_an_error_without_reconnecting(): + def unavailable(text): + raise E6AuthUnavailable("e6 auth library missing: /usr/lib/pitrac/libpitrac_e6.so") + + fake = _FakeE6() + await fake.start() + sim = await _connected(fake, auth=unavailable) + + assert sim.status == "error" + assert "e6 auth library missing" in sim.info()["detail"] + assert sim._reconnect_task is None and sim._writer is None + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_arm_and_disarm_set_armed_and_the_detail(): + fake = _FakeE6() + await fake.start() + sim = await _connected(fake) + assert sim.armed is False + + fake.send(_ARM) + await asyncio.sleep(0.05) + assert sim.armed is True + assert sim.info()["detail"].endswith("armed") + + fake.send(_DISARM) + await asyncio.sleep(0.05) + assert sim.armed is False + assert not sim.info()["detail"].endswith("armed") + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_ping_gets_pong(): + fake = _FakeE6() + await fake.start() + sim = await _connected(fake) + fake.send(_PING) + await asyncio.sleep(0.05) + + assert fake.types()[-1] == "Pong" + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_send_shot_while_disarmed_sends_nothing(): + fake = _FakeE6() + await fake.start() + sim = await _connected(fake) + sent = len(fake.messages) + await sim.send_shot(ShotData(speed=100.0, back_spin=3000)) + await asyncio.sleep(0.05) + + assert len(fake.messages) == sent + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_armed_shot_sends_three_messages_with_the_delay_then_disarms(monkeypatch): + fake = _FakeE6() + await fake.start() + sim = await _connected(fake, inter_message_delay_ms=1) + fake.send(_ARM) + await asyncio.sleep(0.05) + + sleeps = [] + real_sleep = asyncio.sleep + + async def recording_sleep(delay, *args): + sleeps.append(delay) + await real_sleep(delay, *args) + + monkeypatch.setattr(asyncio, "sleep", recording_sleep) + await sim.send_shot(ShotData(speed=101.24, launch_angle=15.4, side_angle=-2.1, back_spin=3000, side_spin=-300)) + monkeypatch.undo() + await asyncio.sleep(0.05) + + assert sleeps == [0.001, 0.001] + assert fake.types()[-3:] == ["SetBallData", "SetClubData", "SendShot"] + assert fake.messages[-3]["BallData"] == { + "BackSpin": 3000.0, + "BallSpeed": 101.2, + "LaunchAngle": 15.4, + "LaunchDirection": -2.1, + "SideSpin": -300.0, + } + assert fake.messages[-2]["ClubData"] == { + "ClubHeadSpeed": 0.0, + "ClubAngleFace": 0.0, + "ClubAnglePath": 0.0, + "ClubHeadSpeedMPH": 0.0, + } + assert sim.armed is False + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_ball_data_is_clamped_to_the_e6_ranges(): + fake = _FakeE6() + await fake.start() + sim = await _connected(fake, inter_message_delay_ms=0) + fake.send(_ARM) + await asyncio.sleep(0.05) + await sim.send_shot(ShotData(speed=300.0, back_spin=30000, side_spin=-7000)) + await asyncio.sleep(0.05) + + ball = next(m for m in fake.messages if m["Type"] == "SetBallData")["BallData"] + assert (ball["BackSpin"], ball["BallSpeed"], ball["SideSpin"]) == (19999.0, 249.9, -5999.0) + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_messages_sharing_a_segment_or_split_across_reads_are_all_handled(): + fake = _FakeE6() + await fake.start() + sim = await _connected(fake) + big = json.dumps({"Type": "SimCommand", "SubType": "ShotComplete", "Details": {"pad": "x" * 5000}}).encode() + fake.send(big + _ARM + _PING) + await asyncio.sleep(0.05) + + assert sim.armed is True + assert fake.types()[-1] == "Pong" + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_write_failure_raises_and_sets_error(): + async def broken_drain(): + raise ConnectionResetError("gone") + + sim = E6Sim(host="127.0.0.1", port=2483, auth=_fake_auth([])) + sim._writer = MagicMock() + sim._writer.drain = broken_drain + sim.armed = True + with pytest.raises(ConnectionResetError): + await sim.send_shot(ShotData(speed=100.0)) + + assert sim.status == "error" + assert sim.armed is False + await sim.disconnect() + + +@pytest.mark.asyncio +async def test_player_club_type_reaches_the_callback(): + fake = _FakeE6() + await fake.start() + clubs = [] + sim = await _connected(fake, on_club=clubs.append) + fake.send(b'{"Type":"SimCommand","SubType":"PlayerDataModified","Details":{"ClubType":"Putter"}}') + await asyncio.sleep(0.05) + fake.send(b'{"Type":"SimCommand","SubType":"PlayerDataModified","Details":{"ClubType":"Driver"}}') + await asyncio.sleep(0.05) + + assert clubs == ["putter", "driver"] + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_disconnect_sends_disconnect_first(): + fake = _FakeE6() + await fake.start() + sim = await _connected(fake) + fake.send(_ARM) + await asyncio.sleep(0.05) + await sim.disconnect() + await asyncio.sleep(0.05) + + assert fake.types()[-1] == "Disconnect" + assert sim.status == "off" + assert sim.armed is False + await fake.stop() diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index 3601fa6c..afd4398b 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -175,6 +175,26 @@ async def test_gspro_club_is_kept_until_it_disconnects(): assert mgr.club is None +@pytest.mark.asyncio +async def test_e6_is_built_and_gates_armed(): + mgr = SimManager(_StubConfig({ + "simulators.e6.enabled": True, "simulators.e6.auto_connect": False, + "simulators.e6.host": "1.2.3.6", "simulators.e6.port": 2483, + "simulators.e6.inter_message_delay_ms": 50, + }), broadcast=None) + mgr.build_sims() + assert [s["name"] for s in mgr.status()] == ["e6"] + e6 = mgr._sims["e6"] + e6._status = "connected" + assert mgr.armed is False + await e6._on_message({"Type": "SimCommand", "SubType": "Arm"}) + assert mgr.armed is True + await e6._on_message({"Type": "SimCommand", "SubType": "PlayerDataModified", "Details": {"ClubType": "Putter"}}) + assert mgr.club == "putter" + await e6._on_message({"Type": "SimCommand", "SubType": "Disarm"}) + assert mgr.armed is False + + @pytest.mark.asyncio async def test_connect_disconnect_by_name(): mgr = SimManager(_StubConfig({}), broadcast=None) From cb4987b754541fe20e32c04a7be1eed2ea463314 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 27 Aug 2026 21:19:55 -0400 Subject: [PATCH 110/156] pass e6 auth text through untouched and time out a silent handshake --- Software/web-server/sims/e6_auth.py | 4 +- Software/web-server/sims/e6_sim.py | 47 ++++---- Software/web-server/sims/gspro_sim.py | 11 +- Software/web-server/sims/ogs_sim.py | 12 +-- Software/web-server/sims/tcp_sim.py | 26 +++-- Software/web-server/tests/test_e6_sim.py | 132 ++++++++++++++++------- 6 files changed, 148 insertions(+), 84 deletions(-) diff --git a/Software/web-server/sims/e6_auth.py b/Software/web-server/sims/e6_auth.py index c4d26de9..bfdf1817 100644 --- a/Software/web-server/sims/e6_auth.py +++ b/Software/web-server/sims/e6_auth.py @@ -33,7 +33,7 @@ def _load() -> ctypes.CDLL: return _lib -def _is_challenge(text: str) -> bool: +def is_challenge(text: str) -> bool: try: message = json.loads(text) except ValueError: @@ -44,7 +44,7 @@ def _is_challenge(text: str) -> bool: def challenge_response(text: str) -> str: """Answer an E6 authentication challenge (a Handshake or Challenge message carrying a Challenge field).""" # Other messages reach code in the closed-source object that crashes this process - if not _is_challenge(text): + if not is_challenge(text): raise E6AuthError("not an e6 authentication challenge") buf = ctypes.create_string_buffer(_BUFFER_SIZE) length = _load().e6_process(text.encode(), buf, _BUFFER_SIZE) diff --git a/Software/web-server/sims/e6_sim.py b/Software/web-server/sims/e6_sim.py index 5cd3f47c..6345e821 100644 --- a/Software/web-server/sims/e6_sim.py +++ b/Software/web-server/sims/e6_sim.py @@ -1,33 +1,30 @@ from __future__ import annotations import asyncio -import json import logging from typing import Callable, Dict, Optional from models import ShotData -from sim_interface import STATUS_CONNECTED, STATUS_ERROR +from sim_interface import STATUS_CONNECTED, STATUS_CONNECTING, STATUS_ERROR from sims import e6_auth -from sims.gspro_sim import _round1 -from sims.ogs_sim import _clamp -from sims.tcp_sim import TcpSim +from sims.tcp_sim import TcpSim, clamp, round1 logger = logging.getLogger(__name__) -_CHALLENGE_TYPES = ("Handshake", "Challenge") +_CLUBS = {"Putter": "putter", "Driver": "driver"} _ZERO_CLUB_DATA = dict.fromkeys(("ClubHeadSpeed", "ClubAngleFace", "ClubAnglePath", "ClubHeadSpeedMPH"), 0.0) def build_ball_data(shot: ShotData) -> Dict[str, object]: # The ranges E6 enforces, which GsE6Results::Format meant to apply ball = { - "BackSpin": _clamp(float(shot.back_spin), -999, 19999), - "BallSpeed": _clamp(float(shot.speed), 0.09, 249.9), + "BackSpin": clamp(float(shot.back_spin), -999, 19999), + "BallSpeed": clamp(float(shot.speed), 0.09, 249.9), "LaunchAngle": float(shot.launch_angle), "LaunchDirection": float(shot.side_angle), - "SideSpin": _clamp(float(shot.side_spin), -5999, 5999), + "SideSpin": clamp(float(shot.side_spin), -5999, 5999), } - return {"Type": "SetBallData", "BallData": {key: _round1(value) for key, value in ball.items()}} + return {"Type": "SetBallData", "BallData": {key: round1(value) for key, value in ball.items()}} class E6Sim(TcpSim): @@ -35,6 +32,7 @@ class E6Sim(TcpSim): display_name = "E6 Connect" READS = True CONNECTED_ON_OPEN = False + AUTH_TIMEOUT_SEC = 10 def __init__( self, @@ -56,6 +54,13 @@ def _teardown_socket(self) -> None: async def _on_connected(self) -> None: await self._send_obj({"Type": "Handshake"}) + self._spawn(self._auth_deadline()) + + async def _auth_deadline(self) -> None: + await asyncio.sleep(self.AUTH_TIMEOUT_SEC) + if self.status == STATUS_CONNECTING: + await self._set_status(STATUS_ERROR, "E6 did not authenticate") + self._schedule_reconnect() async def disconnect(self) -> None: if self._writer is not None: @@ -88,18 +93,21 @@ async def send_shot(self, shot: ShotData) -> None: await self._send_or_reconnect({"Type": "SendShot"}) await self._set_armed(False) - async def _on_message(self, obj: object) -> None: + async def _on_message(self, obj: object, raw: str = "") -> None: if not isinstance(obj, dict): logger.warning(f"E6 sent an unexpected message: {obj!r}") return msg_type = obj.get("Type") - if msg_type in _CHALLENGE_TYPES and "Challenge" in obj: + if e6_auth.is_challenge(raw): + # The exact text both ways: the object's reply is not always valid JSON, and the C++ sent it as is try: - reply = self._auth(json.dumps(obj)) - if reply: - await self._send_or_reconnect(json.loads(reply)) - except (e6_auth.E6AuthError, ValueError) as e: + reply = self._auth(raw) + if not reply: + raise e6_auth.E6AuthError("no reply to the challenge") + except e6_auth.E6AuthError as e: await self._give_up(f"E6 authentication failed: {e}") + return + await self._send_or_reconnect(reply.encode()) elif msg_type == "Authentication": if obj.get("Success") in ("true", True): logger.info("E6 authenticated") @@ -124,9 +132,10 @@ async def _on_command(self, sub_type: object, details: object) -> None: await self._set_armed(False) elif sub_type == "PlayerDataModified": club_type = details.get("ClubType") if isinstance(details, dict) else None - logger.info(f"E6 player data: club {club_type!r}") - if club_type is not None and self._on_club is not None: - self._on_club("putter" if club_type == "Putter" else "driver") + club = _CLUBS.get(club_type) + logger.info(f"E6 player data: club {club_type!r}" + ("" if club else ", ignored")) + if club and self._on_club is not None: + self._on_club(club) elif sub_type == "ShotComplete": logger.info(f"E6 shot complete: {details}") else: diff --git a/Software/web-server/sims/gspro_sim.py b/Software/web-server/sims/gspro_sim.py index ce09c8b3..6c0becbb 100644 --- a/Software/web-server/sims/gspro_sim.py +++ b/Software/web-server/sims/gspro_sim.py @@ -7,7 +7,7 @@ from typing import Callable, Dict, Optional from models import ShotData -from sims.tcp_sim import TcpSim +from sims.tcp_sim import TcpSim, round1 logger = logging.getLogger(__name__) @@ -31,11 +31,6 @@ } -def _round1(value: float) -> float: - # std::round in GsResults::FormatDoubleAsString rounds halves away from zero - return math.copysign(math.floor(abs(value) * 10 + 0.5), value) / 10 - - def _spin_axis(back: float, side: float) -> float: # Same formula as GsResults::GetSpinAxis, so a negative back spin keeps the C++ sign if abs(side) <= 0.0001: @@ -51,7 +46,7 @@ def _message(shot_number: int, ball: Dict[str, float], heartbeat: bool, ball_det "Units": "Yards", "ShotNumber": shot_number, "APIversion": "1", - "BallData": {key: _round1(value) for key, value in ball.items()}, + "BallData": {key: round1(value) for key, value in ball.items()}, "ClubData": _ZERO_CLUB_DATA, "ShotDataOptions": { "ContainsBallData": not heartbeat, @@ -112,7 +107,7 @@ async def send_shot(self, shot: ShotData) -> None: raise ConnectionError("GSPro not connected") await self._send_or_reconnect(build_shot_payload(shot, next(_shot_numbers))) - async def _on_message(self, obj: object) -> None: + async def _on_message(self, obj: object, raw: str = "") -> None: if not isinstance(obj, dict): logger.warning(f"GSPro sent an unexpected message: {obj!r}") return diff --git a/Software/web-server/sims/ogs_sim.py b/Software/web-server/sims/ogs_sim.py index ca807531..6959e5af 100644 --- a/Software/web-server/sims/ogs_sim.py +++ b/Software/web-server/sims/ogs_sim.py @@ -6,15 +6,11 @@ from typing import Dict, Optional from models import ShotData -from sims.tcp_sim import TcpSim +from sims.tcp_sim import TcpSim, clamp _READY = {"type": "device", "status": "ready"} -def _clamp(value: float, low: float, high: float) -> float: - return max(low, min(high, value)) - - def build_shot_payload(shot: ShotData) -> Dict[str, object]: back = float(shot.back_spin) side = float(shot.side_spin) @@ -25,10 +21,10 @@ def build_shot_payload(shot: ShotData) -> Dict[str, object]: "unit": "imperial", "shot": { "ballSpeed": round(float(shot.speed), 1), - "verticalLaunchAngle": round(_clamp(float(shot.launch_angle), 0, 45), 1), - "horizontalLaunchAngle": round(_clamp(float(shot.side_angle), -45, 45), 1), + "verticalLaunchAngle": round(clamp(float(shot.launch_angle), 0, 45), 1), + "horizontalLaunchAngle": round(clamp(float(shot.side_angle), -45, 45), 1), "spinSpeed": spin_speed, - "spinAxis": round(_clamp(spin_axis, -45, 45), 1), + "spinAxis": round(clamp(spin_axis, -45, 45), 1), }, } diff --git a/Software/web-server/sims/tcp_sim.py b/Software/web-server/sims/tcp_sim.py index 14e0146e..7c2fce12 100644 --- a/Software/web-server/sims/tcp_sim.py +++ b/Software/web-server/sims/tcp_sim.py @@ -3,7 +3,8 @@ import asyncio import json import logging -from typing import Coroutine, Dict, List, Optional +import math +from typing import Coroutine, Dict, List, Optional, Union from sim_interface import ( SimInterface, @@ -19,11 +20,20 @@ _MAX_UNPARSED_CHARS = 64 * 1024 +def clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def round1(value: float) -> float: + # std::round in GsResults::FormatDoubleAsString rounds halves away from zero + return math.copysign(math.floor(abs(value) * 10 + 0.5), value) / 10 + + class TcpSim(SimInterface): """A sim that is a TCP client of its simulator, with reconnect. Subclasses frame messages in _encode, send their greeting in _on_connected, - and set READS to have each JSON object the simulator sends passed to _on_message. + and set READS to have each JSON object the simulator sends, with its raw text, passed to _on_message. One that must authenticate first sets CONNECTED_ON_OPEN False and reports connected itself. """ @@ -84,13 +94,13 @@ async def _on_connected(self) -> None: def _encode(self, obj: Dict[str, object]) -> bytes: return json.dumps(obj).encode("utf-8") - async def _send_obj(self, obj: Dict[str, object]) -> None: + async def _send_obj(self, obj: Union[Dict[str, object], bytes]) -> None: if self._writer is None: raise ConnectionError("not connected") - self._writer.write(self._encode(obj)) + self._writer.write(obj if isinstance(obj, bytes) else self._encode(obj)) await self._writer.drain() - async def _send_or_reconnect(self, obj: Dict[str, object]) -> None: + async def _send_or_reconnect(self, obj: Union[Dict[str, object], bytes]) -> None: try: await self._send_obj(obj) except Exception as e: @@ -99,7 +109,7 @@ async def _send_or_reconnect(self, obj: Dict[str, object]) -> None: self._schedule_reconnect() raise - async def _on_message(self, obj: object) -> None: + async def _on_message(self, obj: object, raw: str = "") -> None: pass async def _read_loop(self) -> None: @@ -120,9 +130,9 @@ async def _read_loop(self) -> None: logger.warning(f"{self.display_name} sent unparseable data, dropped: {buf[:200]!r}") buf = "" break - buf = buf[end:] + raw, buf = buf[:end], buf[end:] try: - await self._on_message(obj) + await self._on_message(obj, raw) except Exception as e: logger.warning(f"{self.display_name} message handling failed: {e!r}") if self._reader is not reader: diff --git a/Software/web-server/tests/test_e6_sim.py b/Software/web-server/tests/test_e6_sim.py index b84806ee..b4dcfba6 100644 --- a/Software/web-server/tests/test_e6_sim.py +++ b/Software/web-server/tests/test_e6_sim.py @@ -11,14 +11,17 @@ _ARM = b'{"Type":"SimCommand","SubType":"Arm"}' _DISARM = b'{"Type":"SimCommand","SubType":"Disarm"}' _PING = b'{"Type":"SimCommand","SubType":"Ping"}' +_CHALLENGE = b'{"Type":"Handshake", "Challenge":"abc123","E6Version":"2, 0, 0, 0"}' class _FakeE6: """Asyncio TCP server playing E6 Connect: challenge on Handshake, authenticates any reply, ACKs everything.""" - def __init__(self, auth_reply=b'{"Type":"Authentication","Success":"true"}'): + def __init__(self, auth_reply=b'{"Type":"Authentication","Success":"true"}', challenge=_CHALLENGE): self.messages = [] + self.raw = b"" self.auth_reply = auth_reply + self.challenge = challenge self.writers = [] self._server = None self.port = None @@ -35,14 +38,19 @@ async def _handle(self, reader, writer): chunk = await reader.read(4096) if not chunk: break + self.raw += chunk buf += chunk.decode() while buf := buf.lstrip(): - obj, end = decoder.raw_decode(buf) + try: + obj, end = decoder.raw_decode(buf) + except json.JSONDecodeError: + buf = "" + break buf = buf[end:] self.messages.append(obj) writer.write(json.dumps({"Type": "ACK", "SubType": obj["Type"], "Details": "Success."}).encode()) - if obj["Type"] == "Handshake": - writer.write(b'{"Type":"Handshake","Challenge":"abc123","E6Version":"2, 0, 0, 0"}') + if obj["Type"] == "Handshake" and self.challenge: + writer.write(self.challenge) elif obj["Type"] == "Challenge": writer.write(self.auth_reply) writer.close() @@ -58,14 +66,22 @@ async def stop(self): await self._server.wait_closed() -def _fake_auth(seen): +def _fake_auth(seen, reply='{"Type":"Challenge","Developer":"dev","Hash":"hash"}'): def answer(text): - seen.append(json.loads(text)) - return json.dumps({"Type": "Challenge", "Developer": "dev", "Hash": "hash-" + json.loads(text)["Challenge"]}) + seen.append(text) + return reply return answer +async def _until(predicate, timeout=1.0): + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not predicate(): + assert loop.time() < deadline, "timed out waiting" + await asyncio.sleep(0.005) + + @pytest.fixture(autouse=True) def _enable_sockets_if_plugin_present(): try: @@ -80,7 +96,14 @@ async def _connected(fake, **kwargs): kwargs.setdefault("auth", _fake_auth([])) sim = E6Sim(host="127.0.0.1", port=fake.port, **kwargs) await sim.connect() - await asyncio.sleep(0.05) + await _until(lambda: sim.status != "connecting") + return sim + + +async def _armed(fake, **kwargs): + sim = await _connected(fake, **kwargs) + fake.send(_ARM) + await _until(lambda: sim.armed) return sim @@ -92,11 +115,25 @@ async def test_handshake_is_answered_by_the_auth_function_then_connects(): sim = E6Sim(host="127.0.0.1", port=fake.port, auth=_fake_auth(seen)) await sim.connect() assert sim.status == "connecting" - await asyncio.sleep(0.05) + await _until(lambda: sim.status == "connected") - assert sim.status == "connected" - assert seen[0]["Challenge"] == "abc123" - assert fake.messages[:2] == [{"Type": "Handshake"}, {"Type": "Challenge", "Developer": "dev", "Hash": "hash-abc123"}] + assert seen == [_CHALLENGE.decode()] + assert fake.messages[:2] == [{"Type": "Handshake"}, {"Type": "Challenge", "Developer": "dev", "Hash": "hash"}] + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_auth_reply_is_written_verbatim(): + # The shim unquotes numeric-looking strings, which is not valid JSON but is what the C++ sent + reply = '{"Type":"Challenge","Developer":0123,"Hash":"abc"}' + fake = _FakeE6() + await fake.start() + sim = E6Sim(host="127.0.0.1", port=fake.port, auth=_fake_auth([], reply)) + await sim.connect() + await _until(lambda: fake.raw.endswith(reply.encode())) + + assert fake.raw == b'{"Type": "Handshake"}' + reply.encode() await sim.disconnect() await fake.stop() @@ -131,6 +168,33 @@ def unavailable(text): await fake.stop() +@pytest.mark.asyncio +async def test_empty_auth_reply_is_an_authentication_failure(): + fake = _FakeE6() + await fake.start() + sim = await _connected(fake, auth=_fake_auth([], "")) + + assert sim.status == "error" + assert "authentication failed" in sim.info()["detail"] + assert sim._reconnect_task is None + await sim.disconnect() + await fake.stop() + + +@pytest.mark.asyncio +async def test_no_authentication_in_time_is_an_error_that_reconnects(monkeypatch): + monkeypatch.setattr(E6Sim, "AUTH_TIMEOUT_SEC", 0.05) + fake = _FakeE6(challenge=None) + await fake.start() + sim = await _connected(fake) + + assert sim.status == "error" + assert sim.info()["detail"] == "E6 did not authenticate" + assert sim._reconnect_task is not None and not sim._reconnect_task.done() + await sim.disconnect() + await fake.stop() + + @pytest.mark.asyncio async def test_arm_and_disarm_set_armed_and_the_detail(): fake = _FakeE6() @@ -139,13 +203,11 @@ async def test_arm_and_disarm_set_armed_and_the_detail(): assert sim.armed is False fake.send(_ARM) - await asyncio.sleep(0.05) - assert sim.armed is True + await _until(lambda: sim.armed) assert sim.info()["detail"].endswith("armed") fake.send(_DISARM) - await asyncio.sleep(0.05) - assert sim.armed is False + await _until(lambda: not sim.armed) assert not sim.info()["detail"].endswith("armed") await sim.disconnect() await fake.stop() @@ -157,9 +219,8 @@ async def test_ping_gets_pong(): await fake.start() sim = await _connected(fake) fake.send(_PING) - await asyncio.sleep(0.05) - assert fake.types()[-1] == "Pong" + await _until(lambda: fake.types()[-1] == "Pong") await sim.disconnect() await fake.stop() @@ -171,9 +232,10 @@ async def test_send_shot_while_disarmed_sends_nothing(): sim = await _connected(fake) sent = len(fake.messages) await sim.send_shot(ShotData(speed=100.0, back_spin=3000)) - await asyncio.sleep(0.05) + fake.send(_PING) + await _until(lambda: fake.types()[-1] == "Pong") - assert len(fake.messages) == sent + assert fake.types()[sent:] == ["Pong"] await sim.disconnect() await fake.stop() @@ -182,9 +244,7 @@ async def test_send_shot_while_disarmed_sends_nothing(): async def test_armed_shot_sends_three_messages_with_the_delay_then_disarms(monkeypatch): fake = _FakeE6() await fake.start() - sim = await _connected(fake, inter_message_delay_ms=1) - fake.send(_ARM) - await asyncio.sleep(0.05) + sim = await _armed(fake, inter_message_delay_ms=1) sleeps = [] real_sleep = asyncio.sleep @@ -196,7 +256,7 @@ async def recording_sleep(delay, *args): monkeypatch.setattr(asyncio, "sleep", recording_sleep) await sim.send_shot(ShotData(speed=101.24, launch_angle=15.4, side_angle=-2.1, back_spin=3000, side_spin=-300)) monkeypatch.undo() - await asyncio.sleep(0.05) + await _until(lambda: fake.types()[-1] == "SendShot") assert sleeps == [0.001, 0.001] assert fake.types()[-3:] == ["SetBallData", "SetClubData", "SendShot"] @@ -222,11 +282,9 @@ async def recording_sleep(delay, *args): async def test_ball_data_is_clamped_to_the_e6_ranges(): fake = _FakeE6() await fake.start() - sim = await _connected(fake, inter_message_delay_ms=0) - fake.send(_ARM) - await asyncio.sleep(0.05) + sim = await _armed(fake, inter_message_delay_ms=0) await sim.send_shot(ShotData(speed=300.0, back_spin=30000, side_spin=-7000)) - await asyncio.sleep(0.05) + await _until(lambda: "SetBallData" in fake.types()) ball = next(m for m in fake.messages if m["Type"] == "SetBallData")["BallData"] assert (ball["BackSpin"], ball["BallSpeed"], ball["SideSpin"]) == (19999.0, 249.9, -5999.0) @@ -241,10 +299,9 @@ async def test_messages_sharing_a_segment_or_split_across_reads_are_all_handled( sim = await _connected(fake) big = json.dumps({"Type": "SimCommand", "SubType": "ShotComplete", "Details": {"pad": "x" * 5000}}).encode() fake.send(big + _ARM + _PING) - await asyncio.sleep(0.05) + await _until(lambda: fake.types()[-1] == "Pong") assert sim.armed is True - assert fake.types()[-1] == "Pong" await sim.disconnect() await fake.stop() @@ -272,10 +329,10 @@ async def test_player_club_type_reaches_the_callback(): await fake.start() clubs = [] sim = await _connected(fake, on_club=clubs.append) - fake.send(b'{"Type":"SimCommand","SubType":"PlayerDataModified","Details":{"ClubType":"Putter"}}') - await asyncio.sleep(0.05) - fake.send(b'{"Type":"SimCommand","SubType":"PlayerDataModified","Details":{"ClubType":"Driver"}}') - await asyncio.sleep(0.05) + for club_type in ("Putter", "Iron7", "Driver"): + fake.send(json.dumps({"Type": "SimCommand", "SubType": "PlayerDataModified", "Details": {"ClubType": club_type}}).encode()) + fake.send(_PING) + await _until(lambda: fake.types()[-1] == "Pong") assert clubs == ["putter", "driver"] await sim.disconnect() @@ -286,13 +343,10 @@ async def test_player_club_type_reaches_the_callback(): async def test_disconnect_sends_disconnect_first(): fake = _FakeE6() await fake.start() - sim = await _connected(fake) - fake.send(_ARM) - await asyncio.sleep(0.05) + sim = await _armed(fake) await sim.disconnect() - await asyncio.sleep(0.05) + await _until(lambda: fake.types()[-1] == "Disconnect") - assert fake.types()[-1] == "Disconnect" assert sim.status == "off" assert sim.armed is False await fake.stop() From 39e44de65b8e78ea42c0f209b89c994a1fcb2350 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 28 Aug 2026 11:56:12 -0400 Subject: [PATCH 111/156] move stored gspro and e6 settings to the new sim keys on startup --- Software/web-server/config_manager.py | 43 ++++++++++ .../web-server/tests/test_config_manager.py | 78 ++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index a0dd4e51..cdf098c5 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -24,6 +24,16 @@ logger = logging.getLogger(__name__) +_OLD_SIM = "gs_config.golf_simulator_interfaces" +_RENAMED_SETTINGS = { + f"{_OLD_SIM}.GSPro.kGSProConnectAddress": "simulators.gspro.host", + f"{_OLD_SIM}.GSPro.kGSProConnectPort": "simulators.gspro.port", + f"{_OLD_SIM}.E6.kE6ConnectAddress": "simulators.e6.host", + f"{_OLD_SIM}.E6.kE6ConnectPort": "simulators.e6.port", + f"{_OLD_SIM}.E6.kE6InterMessageDelayMs": "simulators.e6.inter_message_delay_ms", + f"{_OLD_SIM}.kLaunchMonitorIdString": None, +} + def _deep_merge(base: Dict, override: Dict) -> Dict: """Recursively merge override into base""" @@ -126,6 +136,7 @@ def expand_path(path_str: str) -> Path: self.found_legacy_json = self.user_settings_path.exists() or self.calibration_data_path.exists() self._import_json(self._settings, self.user_settings_path) self._import_json(self._calibration, self.calibration_data_path) + self._move_renamed_keys() self.reload() def _load_raw_metadata(self) -> Dict[str, Any]: @@ -217,6 +228,38 @@ def _import_json(self, repo: KeyValueRepository, path: Path) -> None: return logger.info(f"Imported {path} into the {repo.table} table, renamed to {target.name}") + def _move_renamed_keys(self) -> None: + """Move stored settings to their renamed keys in one write; an old sim address also meant connect at startup""" + stored = self._settings.load() + old_keys = [key for key in _RENAMED_SETTINGS if key in stored] + if not old_keys: + return + + schema = self._raw_metadata.get("settings", {}) + moved = {key: value for key, value in stored.items() if key not in _RENAMED_SETTINGS} + for old in old_keys: + new, value = _RENAMED_SETTINGS[old], stored[old] + if new is None: + logger.info(f"Dropping stored setting {old}={value!r}, nothing reads it any more") + continue + if new in stored: + logger.info(f"Dropping stored setting {old}={value!r}, {new} is already set") + continue + meta = schema.get(new, {}) + if meta.get("type") == "integer": + value = _as_integer(value) + if value == meta.get("default"): + logger.info(f"Dropping stored setting {old}={value!r}, it equals the default of {new}") + continue + moved[new] = value + logger.info(f"Moved stored setting {old} to {new}") + if new.endswith(".host") and str(value).strip(): + sim = new.rsplit(".", 1)[0] + moved.setdefault(f"{sim}.enabled", True) + moved.setdefault(f"{sim}.auto_connect", True) + + self._settings.replace_all(moved) + def _build_config_from_metadata(self) -> Dict[str, Any]: """Build configuration from metadata defaults, calibration data, and user overrides""" config = {} diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index b18f8abf..f512e013 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -209,7 +209,9 @@ def test_imports_json_files_once_at_startup(self, db, legacy_dir): })) expected_settings = { - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress": "10.0.0.5", + "simulators.gspro.host": "10.0.0.5", + "simulators.gspro.enabled": True, + "simulators.gspro.auto_connect": True, "cameras.slot1.type": "4", } expected_calibration = { @@ -369,6 +371,80 @@ def test_setting_validation_with_metadata(self, config_manager): assert not is_valid +OLD_SIM = "gs_config.golf_simulator_interfaces" + + +class TestRenamedSimKeys: + def _start(self, db, rows): + KeyValueRepository(db, "settings").replace_all(rows) + ConfigManager(db) + return KeyValueRepository(db, "settings").load() + + def test_gspro_rows_move_and_turn_the_sim_on(self, db): + rows = self._start(db, { + f"{OLD_SIM}.GSPro.kGSProConnectAddress": "10.0.0.5", + f"{OLD_SIM}.GSPro.kGSProConnectPort": 9210, + f"{OLD_SIM}.kLaunchMonitorIdString": "My LM", + "cameras.slot1.type": "4", + }) + + assert rows == { + "simulators.gspro.host": "10.0.0.5", + "simulators.gspro.port": 9210, + "simulators.gspro.enabled": True, + "simulators.gspro.auto_connect": True, + "cameras.slot1.type": "4", + } + + def test_e6_rows_move_and_turn_the_sim_on(self, db): + rows = self._start(db, { + f"{OLD_SIM}.E6.kE6ConnectAddress": "10.0.0.6", + f"{OLD_SIM}.E6.kE6ConnectPort": "2484", + f"{OLD_SIM}.E6.kE6InterMessageDelayMs": 75, + }) + + assert rows == { + "simulators.e6.host": "10.0.0.6", + "simulators.e6.port": 2484, + "simulators.e6.inter_message_delay_ms": 75, + "simulators.e6.enabled": True, + "simulators.e6.auto_connect": True, + } + + def test_empty_address_moves_nothing_and_enables_nothing(self, db): + rows = self._start(db, {f"{OLD_SIM}.GSPro.kGSProConnectAddress": "", "cameras.slot1.type": "4"}) + + assert rows == {"cameras.slot1.type": "4"} + + def test_values_equal_to_the_default_leave_no_row(self, db): + rows = self._start(db, { + f"{OLD_SIM}.GSPro.kGSProConnectPort": "921", + f"{OLD_SIM}.E6.kE6ConnectPort": 2483, + f"{OLD_SIM}.E6.kE6InterMessageDelayMs": 50, + }) + + assert rows == {} + + def test_never_overwrites_a_new_key_already_set(self, db): + rows = self._start(db, { + f"{OLD_SIM}.GSPro.kGSProConnectAddress": "10.0.0.5", + "simulators.gspro.host": "10.0.0.9", + }) + + assert rows == {"simulators.gspro.host": "10.0.0.9"} + + def test_second_start_changes_nothing(self, db): + self._start(db, {f"{OLD_SIM}.GSPro.kGSProConnectAddress": "10.0.0.5"}) + ConfigManager(db).set_config("simulators.gspro.enabled", False) + settings = KeyValueRepository(db, "settings") + before = settings.load() + + ConfigManager(db) + + assert settings.load() == before + assert "simulators.gspro.enabled" not in before + + class TestArrayHandling: """Test suite for array type configuration handling""" From 51d54aa813b73035a085fb13e004177a028bb1c1 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 29 Aug 2026 19:00:48 -0400 Subject: [PATCH 112/156] drop the c++ sim path now that python runs gspro and e6 --- .../ImageProcessing/ImageProcessing.vcxproj | 19 -- .../ImageProcessing.vcxproj.filters | 51 ---- .../create_closed_source_objects.sh | 10 - .../ImageProcessing/e6_auth_shim.cpp | 2 +- .../ImageProcessing/gs_config.cpp | 9 - .../ImageProcessing/gs_e6_interface.cpp | 271 ---------------- .../ImageProcessing/gs_e6_interface.h | 54 ---- .../ImageProcessing/gs_e6_response.cpp | 2 - .../ImageProcessing/gs_e6_results.cpp | 78 ----- .../ImageProcessing/gs_e6_results.h | 31 -- .../LMSourceCode/ImageProcessing/gs_fsm.cpp | 30 +- .../ImageProcessing/gs_gspro_interface.cpp | 222 -------------- .../ImageProcessing/gs_gspro_interface.h | 47 --- .../ImageProcessing/gs_gspro_response.cpp | 130 -------- .../ImageProcessing/gs_gspro_response.h | 46 --- .../ImageProcessing/gs_gspro_results.cpp | 103 ------- .../ImageProcessing/gs_gspro_results.h | 26 -- .../ImageProcessing/gs_gspro_test_server.cpp | 134 -------- .../ImageProcessing/gs_gspro_test_server.h | 58 ---- .../ImageProcessing/gs_options.cpp | 6 - .../LMSourceCode/ImageProcessing/gs_options.h | 6 +- .../ImageProcessing/gs_results.cpp | 55 ---- .../LMSourceCode/ImageProcessing/gs_results.h | 13 - .../ImageProcessing/gs_sim_interface.cpp | 289 ------------------ .../ImageProcessing/gs_sim_interface.h | 121 -------- .../gs_sim_socket_interface.cpp | 283 ----------------- .../ImageProcessing/gs_sim_socket_interface.h | 69 ----- .../ImageProcessing/gs_ui_system.cpp | 14 +- .../ImageProcessing/gs_ui_system.h | 6 + .../LMSourceCode/ImageProcessing/lm_main.cpp | 173 +---------- .../LMSourceCode/ImageProcessing/meson.build | 26 +- Software/web-server/configurations.json | 64 ---- Software/web-server/sims/e6_sim.py | 2 +- Software/web-server/sims/gspro_sim.py | 4 +- Software/web-server/templates/testing.html | 7 - Software/web-server/testing_tools_manager.py | 8 - .../web-server/tests/test_config_manager.py | 22 +- .../web-server/tests/test_pitrac_manager.py | 4 +- .../web-server/tests/utils/mock_factories.py | 8 +- 39 files changed, 46 insertions(+), 2457 deletions(-) delete mode 100755 Software/LMSourceCode/ImageProcessing/create_closed_source_objects.sh delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_e6_interface.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_e6_interface.h delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_e6_results.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_e6_results.h delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_gspro_interface.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_gspro_interface.h delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_gspro_response.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_gspro_response.h delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_gspro_results.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_gspro_results.h delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.h delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_sim_interface.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_sim_interface.h delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.cpp delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.h diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj index edf038b8..3349142f 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj @@ -252,21 +252,13 @@ - - - - - - - - @@ -412,23 +404,15 @@ - - - - - - - - @@ -474,7 +458,6 @@ - @@ -500,8 +483,6 @@ - - diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters index ef5af351..1b19c52b 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters @@ -90,21 +90,6 @@ Source Files - - Source Files - - - Source Files - - - Source Files - - - Source Files - - - Source Files - Source Files @@ -120,15 +105,6 @@ Source Files - - Source Files - - - Source Files - - - Source Files - Resource Files @@ -329,21 +305,6 @@ Header Files - - Header Files - - - Header Files - - - Header Files - - - Header Files - - - Header Files - Header Files @@ -362,15 +323,6 @@ Header Files - - Header Files - - - Header Files - - - Header Files - Header Files @@ -550,12 +502,9 @@ - - - Resource Files diff --git a/Software/LMSourceCode/ImageProcessing/create_closed_source_objects.sh b/Software/LMSourceCode/ImageProcessing/create_closed_source_objects.sh deleted file mode 100755 index e063e756..00000000 --- a/Software/LMSourceCode/ImageProcessing/create_closed_source_objects.sh +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-only */ -# -# Copyright (C) 2022-2025, Verdant Consultants, LLC. -# -#!/bin/bash - -echo "Inserting Closed Source Object Files..." -cp -f $PITRAC_ROOT/ImageProcessing/ClosedSourceObjectFiles/* $PITRAC_ROOT/ImageProcessing/build/pitrac_lm.p -cp -f $PITRAC_ROOT/ImageProcessing/ClosedSourceObjectFiles/* $PITRAC_ROOT/ImageProcessing -touch gs_e6_response.cpp.o $PITRAC_ROOT/ImageProcessing/build/pitrac_lm.p/gs_e6_response.cpp.o diff --git a/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp b/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp index dc3ac04e..2c0a176c 100644 --- a/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp +++ b/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp @@ -63,7 +63,7 @@ namespace golf_sim { static std::string GenerateStringFromJsonTree(const boost::property_tree::ptree& root); }; - // Same as gs_results.cpp + // The object calls this. write_json quotes every value, so numbers and booleans are unquoted here std::string GsResults::GenerateStringFromJsonTree(const boost::property_tree::ptree& root) { std::stringstream ss; boost::property_tree::write_json(ss, root); diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index 9d24d2ab..b1fca62e 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -54,15 +54,6 @@ static void PopulateOptionsFromConfig() { GolfSimConfiguration::SetConstant("gs_config.player.kUsePracticeBalls", options.practice_ball_); GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera1SearchCenterX", options.search_center_x_); GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera1SearchCenterY", options.search_center_y_); - - std::string gspro_address = GetConfigString("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress"); - if (!gspro_address.empty()) { - options.gspro_host_address_ = gspro_address; - } - std::string e6_address = GetConfigString("gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress"); - if (!e6_address.empty()) { - options.e6_host_address_ = e6_address; - } } bool GolfSimConfiguration::Initialize() { diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_interface.cpp b/Software/LMSourceCode/ImageProcessing/gs_e6_interface.cpp deleted file mode 100644 index 5b4ef878..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_interface.cpp +++ /dev/null @@ -1,271 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -// "TruGolf Simulators" and other marks such as E6 may be trademarked by TruGolf, Inc. -// The PiTrac project is not endorsed, sponsored by or associated with TrueGolf products or services. - - -#include - -#ifdef __unix__ // Ignore in Windows environment - -#include -#include -#include -#include - -#include "logging_tools.h" -#include "gs_options.h" -#include "gs_config.h" -#include "gs_events.h" -#include "gs_control_msg.h" - -#include "gs_e6_interface.h" -#include "gs_e6_results.h" -#include "gs_e6_response.h" - -using namespace boost::asio; -using ip::tcp; - - -namespace golf_sim { - - long GsE6Interface::kE6InterMessageDelayMs = 50; - - GsE6Interface::GsE6Interface() { - - if (!GolfSimOptions::GetCommandLineOptions().e6_host_address_.empty()) { - socket_connect_address_ = GolfSimOptions::GetCommandLineOptions().e6_host_address_; - } - else { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress", socket_connect_address_); - } - - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectPort", socket_connect_port_); - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6InterMessageDelayMs", kE6InterMessageDelayMs); - } - - GsE6Interface::~GsE6Interface() { - - } - - bool GsE6Interface::InterfaceIsPresent() { - // TBD - For now, just see if the JSON file has E6 information. - // If it does, assume that the interface is present and has been selected - // for use. - std::string test_socket_connect_address; - if (!GolfSimOptions::GetCommandLineOptions().e6_host_address_.empty()) { - test_socket_connect_address = GolfSimOptions::GetCommandLineOptions().e6_host_address_; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress", test_socket_connect_address); - return true; - } - else { - GS_LOG_TRACE_MSG(trace, "GsE6Interface::InterfaceIsPresent - Not Present."); - return false; - } - return (test_socket_connect_address != ""); - } - - - bool GsE6Interface::Initialize() { - // Setup the socket connect here first so - // that we don't have to repeatedly do so. May also want to - // setup a keep-alive ping to the E6 system. - GS_LOG_TRACE_MSG(trace, "GsE6Interface Initialize called."); - - // Get the connection address from the command line if possible - if (!GolfSimOptions::GetCommandLineOptions().e6_host_address_.empty()) { - socket_connect_address_ = GolfSimOptions::GetCommandLineOptions().e6_host_address_; - } - else { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress", socket_connect_address_); - } - - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectPort", socket_connect_port_); - - if (!GsSimSocketInterface::Initialize()) { - GS_LOG_MSG(error, "GsE6Interface could not Initialize."); - return false; - } - - // Give the new thread a moment to get running - usleep(500); - - initialized_ = true; - - const std::string kE6KHandshakeMessage = "{\"Type\":\"Handshake\"}"; - - SendSimMessage(kE6KHandshakeMessage); - - // We should later receive a handshake message back - - return true; - } - - - void GsE6Interface::DeInitialize() { - - // Send disconnect message to TruGolf before we finish up - std::string results_msg = "{\"Type\":\"Disconnect\"}"; - - SendSimMessage(results_msg); - - GsSimSocketInterface::DeInitialize(); - } - - - void GsE6Interface::SetSimSystemArmed(const bool is_armed) { - boost::lock_guard lock(sim_arming_mutex_); - - GS_LOG_TRACE_MSG(trace, "GsE6Interface::SetSimSystemArmed called."); - - sim_system_is_armed_ = is_armed; - } - - bool GsE6Interface::GetSimSystemArmed() { - boost::lock_guard lock(sim_arming_mutex_); - - return sim_system_is_armed_; - } - - - bool GsE6Interface::SendResults(const GsResults& input_results) { - - GS_LOG_TRACE_MSG(trace, "GsE6Interface::SendResults called."); - - if (!initialized_) { - GS_LOG_MSG(error, "GsE6Interface::SendResults called before the interface was intialized."); - return false; - } - - if (!GetSimSystemArmed()) { - GS_LOG_MSG(warning, "GsE6Interface::SendResults called before the E6 system was armed."); - return false; - } - - if (receive_thread_exited_) { - // If we ended the recieve thread, try re-initializing the connection - - GS_LOG_MSG(error, "GsGSProInterface::SendResults called before the interface was intialized."); - - DeInitialize(); - if (!Initialize()) { - GS_LOG_MSG(error, "GsE6Interface::SendResults called before the interface was intialized."); - return false; - } - } - - GsE6Results results(input_results); - - size_t write_length = -1; - - std::string results_msg = results.Format(); - - GS_LOG_MSG(info, "Sending E6 shot results message:\n" + results_msg); - write_length = SendSimMessage(results_msg); - - if (write_length <= 0) { - GS_LOG_MSG(error, "GsE6Interface::SendResults was not able to send Ball Data."); - return false; - } - - // E6 also requires SendClubData and a SendShot messages along with the ball data - // Give E6 a moment to proces the earlier message - usleep(kE6InterMessageDelayMs * 1000); - - boost::property_tree::ptree root; - root.put("Type", "SetClubData"); - - // Create the three required children objects and add to the JSON root - boost::property_tree::ptree club_data_child; - - // Create a dummy club data - we really don't have this information - // Head speed is feet per second - club_data_child.put("ClubHeadSpeed", GsResults::FormatDoubleAsString(0.0)); // (results.speed_mph_ / 3600.) * 5280.)); - club_data_child.put("ClubAngleFace", GsResults::FormatDoubleAsString(0.0)); - club_data_child.put("ClubAnglePath", GsResults::FormatDoubleAsString(0.0)); - club_data_child.put("ClubHeadSpeedMPH", GsResults::FormatDoubleAsString(0.0)); // results.speed_mph_)); - - root.add_child("ClubData", club_data_child); - - std::string club_data_message = GsE6Results::GenerateStringFromJsonTree(root); - - if (club_data_message == "") { - GS_LOG_MSG(warning, "GsE6Results::Format() returning empty string."); - } - - write_length = SendSimMessage(club_data_message); - - if (write_length <= 0) { - GS_LOG_MSG(error, "GsE6Interface::SendResults was not able to send Club Data."); - return false; - } - - // ShotData - usleep(kE6InterMessageDelayMs * 1000); - - results_msg = "{\"Type\":\"SendShot\"}"; - - write_length = SendSimMessage(results_msg); - - if (write_length <= 0) { - GS_LOG_MSG(error, "GsE6Interface::SendResults was not able to send SendShot message."); - return false; - } - - // If we successfully sent a shot, we assume that E6 is no longer armed - SetSimSystemArmed(false); - - GS_LOG_TRACE_MSG(trace, "Finished Sending E6 results input message:\n" + results.Format()); - - return true; - } - - - std::string GsE6Interface::GenerateResultsDataToSend(const GsResults& input_results) { - - GsE6Results e6_results(input_results); - - std::string results_string = e6_results.Format(); - - GS_LOG_TRACE_MSG(trace, "GsE6Interface::GenerateResultsDataToSend) returning:\n" + results_string); - - return results_string; - } - - - bool GsE6Interface::ProcessReceivedData(const std::string received_data) { - - GsE6Response e6_response; - std::string e6_response_string; - - if (!e6_response.ProcessJson(received_data, e6_response_string)) { - GS_LOG_MSG(error, "Failed GsE6Interface::ProcessReceivedData - Could not process json: " + received_data); - return false; - } - - if (e6_response_string == "") { - // GS_LOG_TRACE_MSG(trace, "GsE6Interface::ProcessReceivedData had no response string to return to E6"); - return true; - } - else { - GS_LOG_TRACE_MSG(trace, "GsE6Interface::ProcessReceivedData about to send response of: " + e6_response_string); - - size_t write_length = SendSimMessage(e6_response_string); - - if (write_length <= 0) { - GS_LOG_MSG(error, "GsE6Interface::ProcessReceivedData failed to send date message: " + e6_response_string); - return false; - } - } - - return true; - } - - - - -} -#endif diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_interface.h b/Software/LMSourceCode/ImageProcessing/gs_e6_interface.h deleted file mode 100644 index e8418530..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_interface.h +++ /dev/null @@ -1,54 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - - // "TruGolf Simulators" and other marks such as E6 may be trademarked by TruGolf, Inc. - // The PiTrac project is not endorsed, sponsored by or associated with TrueGolf products or services. - - -#pragma once - - // Base class for representing and transferring Golf Sim results to the E6 golf simulator - -#include - -#include "gs_results.h" -#include "gs_sim_socket_interface.h" - -using namespace boost::asio; -using ip::tcp; - -namespace golf_sim { - - class GsE6Interface : public GsSimSocketInterface { - - public: - GsE6Interface(); - virtual ~GsE6Interface(); - - // Returns true iff the GSPro interface is to be used - static bool InterfaceIsPresent(); - - // Must be called before SendResults is called. - virtual bool Initialize(); - - // Deals with, for example, shutting down any socket connection - virtual void DeInitialize(); - - virtual bool SendResults(const GsResults& results); - - virtual void SetSimSystemArmed(const bool is_armed); - virtual bool GetSimSystemArmed(); - - protected: - - virtual std::string GenerateResultsDataToSend(const GsResults& results); - - virtual bool ProcessReceivedData(const std::string received_data); - - protected: - static long kE6InterMessageDelayMs; - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_response.cpp b/Software/LMSourceCode/ImageProcessing/gs_e6_response.cpp index 861cb559..aa030f79 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_response.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_e6_response.cpp @@ -20,9 +20,7 @@ #include "gs_events.h" #include "gs_control_msg.h" -#include "gs_e6_interface.h" #include "gs_e6_response.h" -#include "gs_e6_results.h" namespace golf_sim { diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_results.cpp b/Software/LMSourceCode/ImageProcessing/gs_e6_results.cpp deleted file mode 100644 index 6171d693..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_results.cpp +++ /dev/null @@ -1,78 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -// "TruGolf Simulators" and other marks such as E6 may be trademarked by TruGolf, Inc. -// The PiTrac project is not endorsed, sponsored by or associated with TrueGolf products or services. - - -#include -#include -#include - -#include "logging_tools.h" - -#include "gs_e6_results.h" - -namespace golf_sim { - - GsE6Results::GsE6Results() { - } - - GsE6Results::GsE6Results(const GolfBall& ball) : GsResults(ball) { - } - - // TBD - Copy constructor? - GsE6Results::GsE6Results(const GsResults& results) : GsResults(results) { - } - - - GsE6Results::~GsE6Results() { - - } - - - std::string GsE6Results::Format() const { - // Create a JSON object based on https://e6golf.com/E6ConnectV1.html - // Create a property tree object - boost::property_tree::ptree root; - - // Create the three required children objects and add to the JSON root - boost::property_tree::ptree ball_data_child; - boost::property_tree::ptree club_data_child; - boost::property_tree::ptree shot_data_options_child; - - // Root-level values - root.put("Type", "SetBallData"); - - // E6 Enforces certain ranges. Make sure we do, too - int back_spin_rpm = std::min(back_spin_rpm_, 19999); - back_spin_rpm = std::max(back_spin_rpm_, -999); - - float speed_mph = std::min(speed_mph_, (float)249.9); - speed_mph = std::max(speed_mph_, (float)0.09); - - int side_spin_rpm = std::min(side_spin_rpm_, 5999); - side_spin_rpm = std::max(side_spin_rpm_, -5999); - - - // Ball data - some of the values such as tilt are not required and we don't include them - ball_data_child.put("BackSpin", FormatDoubleAsString(back_spin_rpm_)); - ball_data_child.put("BallSpeed", FormatDoubleAsString(speed_mph_)); - ball_data_child.put("LaunchAngle", FormatDoubleAsString(vla_deg_)); - ball_data_child.put("LaunchDirection", FormatDoubleAsString(hla_deg_)); - ball_data_child.put("SideSpin", FormatDoubleAsString(side_spin_rpm_)); - - root.add_child("BallData", ball_data_child); - - std::string result = GenerateStringFromJsonTree(root); - - if (result == "") { - GS_LOG_MSG(warning, "GsE6Results::Format() returning empty string."); - } - - return result; - } - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_results.h b/Software/LMSourceCode/ImageProcessing/gs_e6_results.h deleted file mode 100644 index decbdc9e..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_results.h +++ /dev/null @@ -1,31 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include "gs_results.h" - -// "TruGolf Simulators" and other marks such as E6 may be trademarked by TruGolf, Inc. -// The PiTrac project is not endorsed, sponsored by or associated with TrueGolf products or services. - - - -// Base class for representing and transferring Golf Sim results - -namespace golf_sim { - - class GsE6Results : public GsResults { - - public: - GsE6Results(); - GsE6Results(const GolfBall& ball); - GsE6Results(const GsResults& results); - virtual ~GsE6Results(); - // Will enforce range limits - virtual std::string Format() const; - - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index 0f01de51..5ab56f13 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -26,7 +26,6 @@ #include "ball_image_proc.h" #include "gs_http_client.h" #include "gs_ui_system.h" -#include "gs_sim_interface.h" #include "pulse_strobe.h" #include "libcamera_interface.h" @@ -133,7 +132,7 @@ namespace golf_sim { GsUISystem::ResetCurrentShot(); // If we're already armed, just start waiting for a ball to appear. - if (GsUISystem::SimArmed() && GsSimInterface::GetAllSystemsArmed()) { + if (GsUISystem::SimArmed()) { GolfSimEventElement beginWaitingForBallPlacedEvent{ new GolfSimEvent::BeginWaitingForBallPlaced{ } }; GolfSimEventQueue::QueueEvent(beginWaitingForBallPlacedEvent); @@ -171,7 +170,6 @@ namespace golf_sim { // TBD - see if we need to move this back to the initializating state if (!waitingForBallState.already_sent_waiting_ipc_message) { GsUISystem::SendIPCStatusMessage(GsIPCResultType::kWaitingForBallToAppear); - GsSimInterface::SendHeartbeat(false); } // This check will be called repeatedly by re-queuing events. @@ -189,8 +187,6 @@ namespace golf_sim { } if (found) { - // Inform connected sims that the ball is on the tee. - GsSimInterface::SendHeartbeat(true); if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1Calibrate || GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2Calibrate) { @@ -291,7 +287,7 @@ namespace golf_sim { GS_LOG_MSG(info, "=============== Ball Stabilized - Let's Play Golf! (Waiting for hit)\n\n"); // Whatever happens, this is a new shot with a new shot number - GsSimInterface::IncrementShotCounter(); + GsUISystem::IncrementShotCounter(); // Arm Camera2 thread to start waiting for the external trigger g_cam2_thread.arm(); @@ -312,7 +308,7 @@ namespace golf_sim { if (GolfSimCamera::kLogDiagnosticImagesToUniqueFiles) { // Save a unique version of the webserver image into a directory that will not get // over-written. A unique timestamp will be added to the file name - LoggingTools::LogImage(kWebServerLastTeedBallImage + "_", img, std::vector < cv::Point >{}, false, "", "_Shot_" + std::to_string(GsSimInterface::GetShotCounter())); + LoggingTools::LogImage(kWebServerLastTeedBallImage + "_", img, std::vector < cv::Point >{}, false, "", "_Shot_" + std::to_string(GsUISystem::GetShotCounter())); } @@ -348,7 +344,7 @@ namespace golf_sim { // Wait a moment so that we're not spinning too much sleep(1); - if (GsUISystem::SimArmed() && GsSimInterface::GetAllSystemsArmed()) { + if (GsUISystem::SimArmed()) { GolfSimEventElement beginWaitingForBallPlacedEvent{ new GolfSimEvent::BeginWaitingForBallPlaced{ } }; GolfSimEventQueue::QueueEvent(beginWaitingForBallPlacedEvent); @@ -457,7 +453,7 @@ namespace golf_sim { GsUISystem::SendIPCErrorStatusMessage("GolfSim FSM could not ProcessReceivedCam2Image."); - GS_LOG_MSG(info, "BALL_HIT_CSV, " + std::to_string(GsSimInterface::GetShotCounter()) + ", (carry - Error), (Total - Error), (Side Dest - Error), (Smash Factor - Error), (Club Speed - Error), " + GS_LOG_MSG(info, "BALL_HIT_CSV, " + std::to_string(GsUISystem::GetShotCounter()) + ", (carry - Error), (Total - Error), (Side Dest - Error), (Smash Factor - Error), (Club Speed - Error), " + std::to_string(0) + ", " + std::to_string(0) + ", " + std::to_string(0) + ", " @@ -470,13 +466,6 @@ namespace golf_sim { else { GS_LOG_TRACE_MSG(trace, "Received and processed cam2ImageReceived. Now sending Results to any connected Golf Simulator"); - GsResults results(result_ball); - - - // Get the result to the golf simulator ASAP - if (!GsSimInterface::SendResultsToGolfSims(results)) { - GS_LOG_MSG(error, "GolfSim FSM could not SendResultsToGolfSim."); - } GS_LOG_TRACE_MSG(trace, "Received and processed cam2ImageReceived. Now sending an IPC Results Message:"); @@ -809,8 +798,6 @@ namespace golf_sim { std::this_thread::yield(); - GsSimInterface::DeInitializeSims(); - GS_LOG_TRACE_MSG(trace, "System shutdown complete"); PulseStrobe::DeinitGPIOSystem(); @@ -827,8 +814,6 @@ namespace golf_sim { // These modes use test images or simulate functionality without cameras bool skip_camera = (mode == SystemMode::kTest || mode == SystemMode::kTestSpin || - mode == SystemMode::kTestExternalSimMessage || - mode == SystemMode::kTestGSProServer || mode == SystemMode::kAutomatedTesting); if (!skip_camera) { @@ -849,11 +834,6 @@ namespace golf_sim { return false; } - if (!GsSimInterface::InitializeSims()) { - GS_LOG_MSG(error, "Failed to Initialize the Golf Simulator Interface."); - return false; - } - // Start Camera2 capture thread for the main shot-detection modes // Wait for Camera2's pipeline to finish before Camera1 opens — // libcamera's pipeline handler is not thread-safe for concurrent setup. diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.cpp b/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.cpp deleted file mode 100644 index a9095f27..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.cpp +++ /dev/null @@ -1,222 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include - -#ifdef __unix__ // Ignore in Windows environment - -#include -#include -#include -#include - -#include "logging_tools.h" -#include "gs_options.h" -#include "gs_config.h" -#include "gs_events.h" -#include "gs_control_msg.h" - -#include "gs_gspro_interface.h" -#include "gs_gspro_response.h" -#include "gs_gspro_results.h" - -using namespace boost::asio; -using ip::tcp; - - -namespace golf_sim { - - - GsGSProInterface::GsGSProInterface() { - - // We prefer the command-line setting even if there's one in the .json config file - if (!GolfSimOptions::GetCommandLineOptions().gspro_host_address_.empty()) { - socket_connect_address_ = GolfSimOptions::GetCommandLineOptions().gspro_host_address_; - } - else { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", socket_connect_address_); - } - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", socket_connect_port_); - } - - GsGSProInterface::~GsGSProInterface() { - - } - - bool GsGSProInterface::InterfaceIsPresent() { - - // TBD - For now, just see if the command line has GSPro information. - // If it does, assume that the interface is present and has been selected - // for use. We are not going to use the address in the .json file any more, - // though we will use the port and other settings that are not likely to change. - std::string test_socket_connect_address; - - if (!GolfSimOptions::GetCommandLineOptions().gspro_host_address_.empty()) { - test_socket_connect_address = GolfSimOptions::GetCommandLineOptions().gspro_host_address_; - GS_LOG_TRACE_MSG(trace, "GsGSProInterface::InterfaceIsPresent - kGSProConnectAddress=" + test_socket_connect_address); - return true; - } - else { - GS_LOG_TRACE_MSG(trace, "GsGSProInterface::InterfaceIsPresent - Not Present"); - return false; - } - - return (test_socket_connect_address != ""); - } - - - - bool GsGSProInterface::Initialize() { - // Setup the socket connect here first so - // that we don't have to repeatedly do so. May also want to - // setup a keep-alive ping to the GSPro system. - GS_LOG_TRACE_MSG(trace, "GsGSProInterface Initialize called."); - - if (!GolfSimOptions::GetCommandLineOptions().gspro_host_address_.empty()) { - socket_connect_address_ = GolfSimOptions::GetCommandLineOptions().gspro_host_address_; - } - else { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", socket_connect_address_); - } - - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", socket_connect_port_); - - if (!GsSimSocketInterface::Initialize()) { - GS_LOG_MSG(error, "GsGSProInterface could not Initialize."); - return false; - } - -#ifdef __unix__ // Ignore in Windows environment - // Give the new thread a moment to get running - usleep(500); -#endif - - initialized_ = true; - - // Send an initial "I'm alive" message - GsGSProResults keep_alive_results; - keep_alive_results.result_message_is_keepalive_ = true; - - // TBD - Currently, it doesn't appear we get a response for a keep-alive ? - SendResults(keep_alive_results); - return true; - } - - void GsGSProInterface::DeInitialize() { - - // TBD - Send disconnect message to TruGolf before we finish up - /* - results_msg = "{\"Type\":\"Disconnect\"}"; - - SendSimMessage(results_msg); - */ - GsSimSocketInterface::DeInitialize(); - } - - - void GsGSProInterface::SetSimSystemArmed(const bool is_armed) { - boost::lock_guard lock(sim_arming_mutex_); - - GS_LOG_TRACE_MSG(trace, "GsGSProInterface::SetSimSystemArmed called."); - - sim_system_is_armed_ = is_armed; - } - - bool GsGSProInterface::GetSimSystemArmed() { - boost::lock_guard lock(sim_arming_mutex_); - - // The GSPro system is always ready to receive shot information, - // at least as far as we know - return true; - } - - bool GsGSProInterface::SendResults(const GsResults& input_results) { - - if (!initialized_) { - GS_LOG_MSG(error, "GsGSProInterface::SendResults called before the interface was intialized."); - return false; - } - - if (receive_thread_exited_) { - GS_LOG_MSG(error, "GsGSProInterface::SendResults called before the interface was intialized."); - - // If we ended the receive thread, try re-initializing the connection - DeInitialize(); - if (!Initialize()) { - GS_LOG_MSG(error, "GsGSProInterface::SendResults called before the interface was intialized."); - return false; - } - } - - GsGSProResults results(input_results); - - GS_LOG_TRACE_MSG(trace, "Sending GSPro results input message:\n" + results.Format()); - - try { - static std::array buf; - boost::system::error_code error; - - std::string results_msg = results.Format(); - - size_t write_length = SendSimMessage(results_msg); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestExternalSimMessage - Error was: " + std::string(e.what())); - return false; - } - - return true; - } - - - - std::string GsGSProInterface::GenerateResultsDataToSend(const GsResults& input_results) { - - GsGSProResults gspro_results(input_results); - return gspro_results.Format(); - } - - bool GsGSProInterface::ProcessReceivedData(const std::string received_data) { - - GsGSProResponse gspro_response; - if (!gspro_response.ParseJson(received_data)) { - GS_LOG_MSG(error, "Failed TestExternalSimMessage - Could not parse json: " + received_data); - return false; - } - - // May need to enter a club-change control message - if (gspro_response.return_code_ == GsGSProResponse::ReturnCode::kPlayerInformation) { - GS_LOG_MSG(info, "Received GSPro kPlayerInformation Result of: \n" + gspro_response.Format()); - - GsIPCControlMsgType club_instruction; - - if (gspro_response.player_club_ == GsGSProResponse::PlayerClub::kPutter) { - club_instruction = GsIPCControlMsgType::kClubChangeToPutter; - } - else if (gspro_response.player_club_ == GsGSProResponse::PlayerClub::kDriver) { - club_instruction = GsIPCControlMsgType::kClubChangeToDriver; - } - else { - GS_LOG_MSG(warning, "Received GSPro unknown club information. Player_club was: " + std::to_string((int)gspro_response.player_club_)); - club_instruction = GsIPCControlMsgType::kUnknown; - } - - // The the instruction to switch clubs to the main FSM - GolfSimEventElement control_message{ new GolfSimEvent::ControlMessage{ club_instruction } }; - GolfSimEventQueue::QueueEvent(control_message); - } - else { - GS_LOG_MSG(info, "GsSimSocketInterface::ProcessReceivedData Received unknown GSPro result type. Result was: \n" + gspro_response.Format()); - } - - return true; - } - - - - -} -#endif diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.h b/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.h deleted file mode 100644 index 64f34699..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.h +++ /dev/null @@ -1,47 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include - -#include "gs_results.h" -#include "gs_sim_socket_interface.h" - -using namespace boost::asio; -using ip::tcp; - -// Base class for representing and transferring Golf Sim results - -namespace golf_sim { - - class GsGSProInterface : public GsSimSocketInterface { - - public: - GsGSProInterface(); - virtual ~GsGSProInterface(); - - // Returns true iff the GSPro interface is to be used - static bool InterfaceIsPresent(); - - // Must be called before SendResults is called. - virtual bool Initialize(); - - // Deals with, for example, shutting down any socket connection - virtual void DeInitialize(); - - virtual bool SendResults(const GsResults& results); - - virtual void SetSimSystemArmed(const bool is_armed); - virtual bool GetSimSystemArmed(); - - protected: - - virtual std::string GenerateResultsDataToSend(const GsResults& results); - - virtual bool ProcessReceivedData(const std::string received_data); - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_response.cpp b/Software/LMSourceCode/ImageProcessing/gs_gspro_response.cpp deleted file mode 100644 index 48be3a81..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_response.cpp +++ /dev/null @@ -1,130 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include -#include - -#include "logging_tools.h" - -#include "gs_gspro_response.h" - -namespace golf_sim { - - GsGSProResponse::GsGSProResponse() { - } - - - bool GsGSProResponse::ParseJson(const std::string& gspro_json_string) { - - int return_code = 0; - std::string message_str; - std::string handedness_str; - std::string club_str; - - std::stringstream ss; - ss << gspro_json_string; - - try { - boost::property_tree::ptree pt; - boost::property_tree::read_json(ss, pt); - - GS_LOG_TRACE_MSG(trace, "GsGSProResponse::Parsing return_code and message_str."); - - return_code = pt.get("Code", 0); - message_str = pt.get("Message", ""); - - // Player handedness and club may not be set - boost::optional< boost::property_tree::ptree& > child = pt.get_child_optional("Player"); - if (!child) { - GS_LOG_MSG(warning, "GsGSProResponse::ParseJson - No player information was provided."); - } - else { - handedness_str = pt.get("Player.Handed", ""); - club_str = pt.get("Player.Club", ""); - } - } - catch (std::exception const& e) - { - // For now, return true even if we failed - TBD - need to figure out what garbage at end means - // from the boost library - GS_LOG_MSG(error, "GsGSProResponse::ParseJson failed to parse GSPro response: " + std::string(e.what())); - return true; - } - - message_ = message_str; - - if (handedness_str == "RH") { - player_handed_ = PlayerHandedness::kRightHanded; - } - else if (handedness_str == "LH") { - player_handed_ = PlayerHandedness::kLeftHanded; - } - else { - // Don't bail even if this is not set - GS_LOG_MSG(warning, "GsGSProResponse::ParseJson received unknown player handedness value from GSPro response:" + handedness_str); - } - - if (club_str == "DR") { - player_club_ = PlayerClub::kDriver; - } - else if (club_str == "PT") { - player_club_ = PlayerClub::kPutter; - } - else { - GS_LOG_MSG(warning, "GsGSProResponse::ParseJson received unknown player club value from GSPro response:" + club_str + ". Defaulting to Driver"); - player_club_ = PlayerClub::kDriver; - } - - switch (return_code) { - case 200: { - return_code_ = kShotReceivedSuccessfully; - break; - } - - case 201: { - return_code_ = kPlayerInformation; - break; - } - - case 501: { - return_code_ = k501Failure; - break; - } - - default: { - if (return_code == 500 || - (return_code > 501 && return_code < 600)) { - return_code_ = kShotOtherFailure; - } - else { - GS_LOG_MSG(error, "Received unknown return_code response from GSPro: " + std::to_string(return_code)); - return_code_ = kShotOtherFailure; - } - break; - } - } - - return true; - } - - GsGSProResponse::~GsGSProResponse() { - - } - - std::string GsGSProResponse::Format() const { - std::string s; - - std::string handed_str = (player_handed_ == PlayerHandedness::kLeftHanded) ? "LH" : "RH"; - std::string club_str = (player_club_ == PlayerClub::kDriver) ? "Driver" : "Putter"; - - s += "Return Code: " + std::to_string(return_code_) + "."; - s += " Message: " + message_ + "\n"; - s += " Player.Handed: " + handed_str; - s += " Player.Club: " + club_str; - - return s; - } - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_response.h b/Software/LMSourceCode/ImageProcessing/gs_gspro_response.h deleted file mode 100644 index 175901ef..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_response.h +++ /dev/null @@ -1,46 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - - -// Class for representing, parsing and transferring Golf Sim responses from GsPro - -namespace golf_sim { - - class GsGSProResponse { - - public: - enum ReturnCode { - kShotReceivedSuccessfully = 200, - kPlayerInformation = 201, - k501Failure = 501, - kShotOtherFailure = 599 - }; - - enum PlayerHandedness { - kRightHanded = 0, - kLeftHanded = 1 - }; - - enum PlayerClub { - kDriver = 0, - kPutter = 1 - }; - - public: - GsGSProResponse(); - bool ParseJson(const std::string& gspro_json_string); - virtual ~GsGSProResponse(); - virtual std::string Format() const; - - ReturnCode return_code_ = ReturnCode::kShotReceivedSuccessfully; - std::string message_ = "Not Set"; - PlayerHandedness player_handed_ = PlayerHandedness::kRightHanded; - PlayerClub player_club_ = PlayerClub::kDriver; - - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_results.cpp b/Software/LMSourceCode/ImageProcessing/gs_gspro_results.cpp deleted file mode 100644 index 2709fb14..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_results.cpp +++ /dev/null @@ -1,103 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include -#include "gs_format_lib.h" -#include -#include - -#include "logging_tools.h" - -#include "gs_gspro_results.h" - -namespace golf_sim { - - GsGSProResults::GsGSProResults() { - } - - GsGSProResults::GsGSProResults(const GolfBall& ball) : GsResults(ball) { - } - - // TBD - Copy constructor? - GsGSProResults::GsGSProResults(const GsResults& results) : GsResults(results) { - } - - - GsGSProResults::~GsGSProResults() { - - } - - - std::string GsGSProResults::Format() const { - // Create a JSON object based on https://gsprogolf.com/GSProConnectV1.html - // Create a property tree object - boost::property_tree::ptree root; - - // Create the three required children objects and add to the JSON root - boost::property_tree::ptree ball_data_child; - boost::property_tree::ptree club_data_child; - boost::property_tree::ptree shot_data_options_child; - - // Root-level values - root.put("DeviceID", "PiTrac LM 0.1"); - root.put("Units", "Yards"); - // The shot number should be increased with each shot - root.put("ShotNumber", shot_number_); - root.put("APIversion", "1"); - - // Ball data - some of these values are not required - ball_data_child.put("Speed", FormatDoubleAsString(speed_mph_)); - ball_data_child.put("SpinAxis", FormatDoubleAsString(GetSpinAxis())); - ball_data_child.put("TotalSpin", "0.0"); - ball_data_child.put("BackSpin", FormatDoubleAsString(back_spin_rpm_)); - ball_data_child.put("SideSpin", FormatDoubleAsString(side_spin_rpm_)); - ball_data_child.put("HLA", FormatDoubleAsString(hla_deg_)); - ball_data_child.put("VLA", FormatDoubleAsString(vla_deg_)); - // ball_data_child.put("CarryDistance", 0.0); - - // Club data - we don't currently implement any of this, but - // just to be safe, we will still send the information - club_data_child.put("Speed", "0.0"); - club_data_child.put("AngleOfAttack", "0.0"); - club_data_child.put("FaceToTarget", "0.0"); - club_data_child.put("Lie", "0.0"); - club_data_child.put("Loft", "0.0"); - club_data_child.put("Path", "0.0"); - club_data_child.put("SpeedAtImpact", "0.0"); - club_data_child.put("VerticalFaceImpact", "0.0"); - club_data_child.put("HorizontalFaceImpact", "0.0"); - club_data_child.put("ClosureRate", "0.0"); - - if (!result_message_is_keepalive_) { - // Only the ball data is valid - shot_data_options_child.put("ContainsBallData", true); - shot_data_options_child.put("ContainsClubData", false); - // TBD - Consider if we want to send the next two values in a heartbeat? - shot_data_options_child.put("LaunchMonitorIsReady", true); - shot_data_options_child.put("LaunchMonitorBallDetected", heartbeat_ball_detected_); - shot_data_options_child.put("IsHeartBeat", false); - } - else { - shot_data_options_child.put("ContainsBallData", false); - shot_data_options_child.put("ContainsClubData", false); - shot_data_options_child.put("LaunchMonitorIsReady", true); - shot_data_options_child.put("LaunchMonitorBallDetected", heartbeat_ball_detected_); - shot_data_options_child.put("IsHeartBeat", true); - } - - root.add_child("BallData", ball_data_child); - root.add_child("ClubData", club_data_child); - root.add_child("ShotDataOptions", shot_data_options_child); - - std::string result = GenerateStringFromJsonTree(root); - - if (result == "") { - GS_LOG_MSG(warning, "GsGSProResults::Format() returning empty string."); - } - - return result; - } - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_results.h b/Software/LMSourceCode/ImageProcessing/gs_gspro_results.h deleted file mode 100644 index 62e48b06..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_results.h +++ /dev/null @@ -1,26 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include "gs_results.h" - - -// Class for representing and transferring Golf Sim results to GSPro - -namespace golf_sim { - - class GsGSProResults : public GsResults { - - public: - GsGSProResults(); - GsGSProResults(const GolfBall& ball); - GsGSProResults(const GsResults& results); - virtual ~GsGSProResults(); - virtual std::string Format() const; - - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.cpp b/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.cpp deleted file mode 100644 index 02c62983..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.cpp +++ /dev/null @@ -1,134 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include -#include -#include -#include -#include - -#include "logging_tools.h" -#include "gs_options.h" -#include "gs_config.h" - -#include "gs_gspro_test_server.h" - - -using boost::asio::ip::tcp; - - -namespace golf_sim { - - GsGSProConnection::pointer GsGSProConnection::Create(boost::asio::io_context& io_context, int port_number) - { - return pointer(new GsGSProConnection(io_context, port_number)); - } - - tcp::socket& GsGSProConnection::GsGSProConnection::GetSocket() - { - return socket_; - } - - std::string GsGSProConnection::GenerateResponseString() - { - std::string response_string = "{\n\ - \"Code\": 201, \ - \"Message\" : \"GSPro Player Information\", \ - \"Player\" : {\n \ - \"Handed\": \"RH\",\n \ - \"Club\" : \"DR\"\n \ - } }"; - - return response_string; - } - - void GsGSProConnection::Start() - { - std::array buf; - boost::system::error_code error; - - - std::string buffer_string; - - while (true) { - GS_LOG_TRACE_MSG(trace, "About to read data."); - size_t len = socket_.read_some(boost::asio::buffer(buf), error); - if (len == 0) { - GS_LOG_MSG(warning, "Received a 0-length string from GSPro server."); - } - else { - // Null-terminate the string - buf[len] = (char)0; - buffer_string += std::string(buf.data()); - GS_LOG_TRACE_MSG(trace, " Read some data (" + std::to_string(len) + " bytes) : " + buffer_string); - } - - if (error == boost::asio::error::eof) { - GS_LOG_MSG(error, "Received unexpected EOF from the Launch Monitor."); - return; // Connection closed cleanly by peer. - } - else if (error) { - GS_LOG_MSG(error, "Received unexpected error from the Launch Monitor."); - throw boost::system::system_error(error); // Some other error. - } - - GS_LOG_TRACE_MSG(trace, "Received the following message from the Launch Monitor: " + buffer_string); - - message_ = GenerateResponseString(); - - GS_LOG_TRACE_MSG(trace, "Sending the following message from the GSPro simulated server: " + message_); - boost::asio::async_write(socket_, boost::asio::buffer(message_), - boost::bind(&GsGSProConnection::HandleWrite, shared_from_this(), - boost::asio::placeholders::error, - boost::asio::placeholders::bytes_transferred)); - } - } - - GsGSProConnection::GsGSProConnection(boost::asio::io_context& io_context, int port_number) - : socket_(io_context) - { - } - - void GsGSProConnection::HandleWrite(const boost::system::error_code& error, - size_t bytes_transferred) { - GS_LOG_TRACE_MSG(trace, "bytes_transferred: " + std::to_string(bytes_transferred)); - } - - -//================== NEXT CLASS HERE ==================== - - - GsGSProTestServer::GsGSProTestServer(boost::asio::io_context& io_context, int port_number) : io_context_(io_context), - acceptor_(io_context, tcp::endpoint(tcp::v4(), port_number)) - { - port_number_ = port_number; - StartAccept(); - } - - void GsGSProTestServer::StartAccept() - { - GS_LOG_TRACE_MSG(trace, "GsGSProTestServer::StartAccept. port_number_: " + std::to_string(port_number_)); - - GsGSProConnection::pointer new_connection = - GsGSProConnection::Create(io_context_, port_number_); - - acceptor_.async_accept(new_connection->GetSocket(), - boost::bind(&GsGSProTestServer::HandleAccept, this, new_connection, - boost::asio::placeholders::error)); - } - - void GsGSProTestServer::HandleAccept(GsGSProConnection::pointer new_connection, - const boost::system::error_code& error) - { - if (!error) - { - new_connection->Start(); - } - - StartAccept(); - } - - -} \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.h b/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.h deleted file mode 100644 index 0d5cb4ec..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.h +++ /dev/null @@ -1,58 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -// Just for testing. This class simulates the GSPro golf simulator interface. - -#pragma once - -#include -#include -#include -#include - -using boost::asio::ip::tcp; - - -namespace golf_sim { - - class GsGSProConnection : public std::enable_shared_from_this - { - public: - typedef std::shared_ptr pointer; - - static pointer Create(boost::asio::io_context& io_context, int port_number); - - tcp::socket& GetSocket(); - - std::string GenerateResponseString(); - - void Start(); - - private: - GsGSProConnection(boost::asio::io_context& io_context, int port_number); - - void HandleWrite(const boost::system::error_code& error, size_t bytes_transferred); - - tcp::socket socket_; - std::string message_; - }; - - class GsGSProTestServer - { - public: - GsGSProTestServer(boost::asio::io_context& io_context, int port_number); - - private: - void StartAccept(); - - void HandleAccept(GsGSProConnection::pointer new_connection, - const boost::system::error_code& error); - - boost::asio::io_context& io_context_; - int port_number_ = 0; - tcp::acceptor acceptor_; - }; - -} \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.cpp b/Software/LMSourceCode/ImageProcessing/gs_options.cpp index 1e6f6f5e..6ca72fdb 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_options.cpp @@ -63,8 +63,6 @@ bool GolfSimOptions::Parse(int argc, char *argv[]) { "test_spin", SystemMode::kTestSpin }, { "camera1_ball_location", SystemMode::kCamera1BallLocation }, { "camera2_ball_location", SystemMode::kCamera2BallLocation }, - { "test_sim_message", SystemMode::kTestExternalSimMessage }, - { "test_gspro_server", SystemMode::kTestGSProServer }, { "automated_testing", SystemMode::kAutomatedTesting }, { "camera1AutoCalibrate", SystemMode::kCamera1AutoCalibrate }, { "camera2AutoCalibrate", SystemMode::kCamera2AutoCalibrate }, @@ -153,10 +151,6 @@ void GolfSimOptions::Print() const std::cout << " base_image_logging_dir_: " << base_image_logging_dir_ << std::endl; if (!web_server_share_dir_.empty()) std::cout << " web_server_share_dir: " << web_server_share_dir_ << std::endl; - if (!e6_host_address_.empty()) - std::cout << " e6_host_address: " << e6_host_address_ << std::endl; - if (!gspro_host_address_.empty()) - std::cout << " gspro_host_address: " << gspro_host_address_ << std::endl; std::cout << " web_server_port: " << std::to_string(web_server_port_) << std::endl; std::cout << " pulse_test: " << std::to_string(perform_pulse_test_) << std::endl; std::cout << " golfer_orientation: " << golfer_orientation_string_ << std::endl; diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.h b/Software/LMSourceCode/ImageProcessing/gs_options.h index f0dd53a6..9fe15beb 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.h +++ b/Software/LMSourceCode/ImageProcessing/gs_options.h @@ -24,8 +24,6 @@ namespace golf_sim { kTestSpin = 7, kCamera1BallLocation = 8, kCamera2BallLocation = 9, - kTestExternalSimMessage = 10, - kTestGSProServer = 11, kAutomatedTesting = 12, kCamera1AutoCalibrate = 13, kCamera2AutoCalibrate = 14, @@ -71,7 +69,7 @@ namespace golf_sim { ("version", value(&version_)->default_value(false)->implicit_value(true), "Displays the build version number") ("system_mode", value(&system_mode_string_)->default_value("test"), - "Set the system's operating mode (camera1, camera1_test_standalone, camera1Calibrate, camera2Calibrate, test_spin, camera1_ball_location, camera2_ball_location, test_gspro_server, automated_testing, camera1AutoCalibrate, camera2AutoCalibrate, test)") + "Set the system's operating mode (camera1, camera1_test_standalone, camera1Calibrate, camera2Calibrate, test_spin, camera1_ball_location, camera2_ball_location, automated_testing, camera1AutoCalibrate, camera2AutoCalibrate, test)") ("shutdown", value(&shutdown_)->default_value(false)->implicit_value(true), "Request clean shutdown") ("cam_still_mode", value(&camera_still_mode_)->default_value(false)->implicit_value(true), @@ -137,8 +135,6 @@ namespace golf_sim { std::string msg_broker_address_; std::string base_image_logging_dir_; std::string web_server_share_dir_; - std::string e6_host_address_; - std::string gspro_host_address_; std::string golfer_orientation_string_ = "right_handed"; SystemMode system_mode_ = kTest; LoggingLevel logging_level_ = kWarn; diff --git a/Software/LMSourceCode/ImageProcessing/gs_results.cpp b/Software/LMSourceCode/ImageProcessing/gs_results.cpp index a94b5b3b..4e75c02a 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_results.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_results.cpp @@ -5,8 +5,6 @@ // Representation of the results of processing a golf shot -#include -#include "gs_format_lib.h" #include "math.h" #include "logging_tools.h" #include "cv_utils.h" @@ -32,9 +30,6 @@ namespace golf_sim { // but this is a reasonable default for now club_type_ = GolfSimClubs::GetCurrentClubType(); - // Real shot data implies a ball was definitely detected. - heartbeat_ball_detected_ = true; - // TBD - Even though this is a constructor, it might be a reasonable // place to calculate the Carry yardarge. } @@ -63,54 +58,4 @@ namespace golf_sim { return s; } - std::string GsResults::FormatDoubleAsString(const double original_value) { - - double value = std::round(original_value * 10.0) / 10.0; - - auto s = GS_FORMATLIB_FORMAT("{: <1.1f}", value); - - std::string result; - - // If we don't quote 0.0, it ends up as 0, and some systems don't like that - if (s == "0.0") { - result = "0.0"; - } - else { - result = s; - } - - return result; - } - - std::string GsResults::GenerateStringFromJsonTree(const boost::property_tree::ptree& root) { - - // Write the property tree to a JSON string - std::stringstream ss; - boost::property_tree::write_json(ss, root); - - // Get the JSON string - std::string json_string = ss.str(); - - // Remove any quotes around data values that should be numbers - // Apparently this is a deficiency in the boost library, see - // https://stackoverflow.com/questions/2855741/why-does-boost-property-tree-write-json-save-everything-as-string-is-it-possibl - - std::regex reg("\\\"([+-]?[0-9]+\\.{0,1}[0-9]*)\\\""); - std::string result = std::regex_replace(json_string, reg, "$1"); - - std::string subStringToRemove = "\"true\""; - std::string subStringToReplace = "true"; - boost::replace_all(result, subStringToRemove, subStringToReplace); - - subStringToRemove = "\"false\""; - subStringToReplace = "false"; - boost::replace_all(result, subStringToRemove, subStringToReplace); - - subStringToRemove = "\"APIversion\": 1,"; - subStringToReplace = "\"APIversion\": \"1\","; - boost::replace_all(result, subStringToRemove, subStringToReplace); - - return result; - } - } diff --git a/Software/LMSourceCode/ImageProcessing/gs_results.h b/Software/LMSourceCode/ImageProcessing/gs_results.h index ac2e9ee8..aea5a7ff 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_results.h +++ b/Software/LMSourceCode/ImageProcessing/gs_results.h @@ -33,13 +33,6 @@ namespace golf_sim { // to the right. float GetSpinAxis() const; - // Deals with problem where Boost will put double-quotes around double values - static std::string FormatDoubleAsString(const double value); - - // Helper that converts a boost JSON tree into a string. Includes processing that - // will remove extraneous quotes. - static std::string GenerateStringFromJsonTree(const boost::property_tree::ptree& root); - public: long shot_number_ = 0; @@ -49,12 +42,6 @@ namespace golf_sim { int back_spin_rpm_ = 0; int side_spin_rpm_ = 0; // Negative is left (counter-clockwise from above ball) GolfSimClubs::GsClubType club_type_ = GolfSimClubs::GsClubType::kNotSelected; - - // Some systems need a keep-alive - bool result_message_is_keepalive_ = false; - bool heartbeat_launch_monitor_ready_ = true; - bool heartbeat_ball_detected_ = false; - }; } diff --git a/Software/LMSourceCode/ImageProcessing/gs_sim_interface.cpp b/Software/LMSourceCode/ImageProcessing/gs_sim_interface.cpp deleted file mode 100644 index c1250bb6..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_sim_interface.cpp +++ /dev/null @@ -1,289 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include "logging_tools.h" -#include "cv_utils.h" -#include "gs_options.h" -#include "gs_config.h" - -#include "gs_sim_interface.h" -#include "gs_gspro_interface.h" -#include "gs_e6_interface.h" - -namespace golf_sim { - - /* TBD - REMOVE - No longer static - bool GsSimInterface::sim_system_is_armed_ = false; - boost::mutex GsSimInterface::sim_arming_mutex_; - */ - std::string GsSimInterface::launch_monitor_id_string_ = "PiTrac LM 0.1"; - - std::vector GsSimInterface::interfaces_; - bool GsSimInterface::sims_initialized_ = false; - - // The first shot number the golf simulator receives should be 1, not 0, and - // the system will increment the counter first before storing information - long GsSimInterface::shot_counter_ = 0; - - - GsSimInterface::GsSimInterface() { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.kLaunchMonitorIdString", launch_monitor_id_string_); - } - - GsSimInterface::~GsSimInterface() { - } - - bool GsSimInterface::InitializeSims() { - - GS_LOG_TRACE_MSG(trace, "GsSimInterface::InitializeSims()"); - - // Create and add an interface to the global vector of interfaces - // for each configured sim - -#ifdef __unix__ // Ignore in Windows environment - - if (GsGSProInterface::InterfaceIsPresent()) { - GS_LOG_TRACE_MSG(trace, "GSPro simulator interface detected."); - - GsGSProInterface* gspro_sim = new GsGSProInterface(); - if (gspro_sim == nullptr) { - GS_LOG_MSG(error, "Could not create a GSPro simulator interface."); - return false; - } - - gspro_sim->simulator_type_ = GolfSimulatorType::kGSPro; - - interfaces_.push_back(gspro_sim); - - if (!gspro_sim->Initialize()) { - GS_LOG_MSG(error, "GSPro simulator interface could not be initialized."); - return false; - } - } - - if (GsE6Interface::InterfaceIsPresent()) { - GS_LOG_TRACE_MSG(trace, "E6 simulator interface detected."); - GsE6Interface* e6_sim = new GsE6Interface(); - if (e6_sim == nullptr) { - GS_LOG_MSG(error, "Could not create an E6 simulator interface."); - return false; - } - - e6_sim->simulator_type_ = GolfSimulatorType::kE6; - - interfaces_.push_back(e6_sim); - - if (!e6_sim->Initialize()) { - GS_LOG_MSG(error, "E6 simulator interface could not be initialized."); - return false; - } - } - - if (interfaces_.size() == 0) { - GS_LOG_TRACE_MSG(trace, "No simulator interface detected."); - } -#endif - shot_counter_ = 0; - - sims_initialized_ = true; - - return true; - } - - - bool GsSimInterface::SimIsConnected() { - - GS_LOG_TRACE_MSG(trace, "GsSimInterface::SimIsConnected()"); - - if (!sims_initialized_) { - // If we're not even initialized, there can't be any connected golf sims. - return false; - } - - bool sim_is_connected = false; - -#ifdef __unix__ // Ignore in Windows environment - - for (auto interface : interfaces_) { - if (interface != nullptr) { - sim_is_connected = true; - continue; - } - } -#endif - return sim_is_connected; - } - - void GsSimInterface::DeInitializeSims() { - - GS_LOG_TRACE_MSG(trace, "GsSimInterface::DeInitializeSims()"); - -#ifdef __unix__ // Ignore in Windows environment - - for (auto interface : interfaces_) { - if (interface == nullptr) { - GS_LOG_MSG(error, "GsSimInterface::DeInitializeSims() found a null interface"); - continue; - } - - interface->DeInitialize(); - delete interface; - } -#endif - sims_initialized_ = false; - } - - - void GsSimInterface::SetSimSystemArmed(const bool is_armed) { - boost::lock_guard lock(sim_arming_mutex_); - - // At the generic level, we'll just allow for setting and getting - // the system-armed status even though this is a virtual interface - GS_LOG_TRACE_MSG(trace, "GsSimInterface::SetSimSystemArmed called."); - - sim_system_is_armed_ = is_armed; - } - - bool GsSimInterface::GetSimSystemArmed() { - boost::lock_guard lock(sim_arming_mutex_); - - if (!SimIsConnected()) { - // If no sims, then consider the system armed as a reasonable fallback behavior - return true; - } - - return sim_system_is_armed_; - } - - - int GsSimInterface::SendSimMessage(const std::string& message) { - GS_LOG_MSG(warning, "GsSimInterface::SendSimMessage - message was:\n" + message); - return 0; - } - - - bool GsSimInterface::SendResultsToGolfSims(const GsResults& input_results) { - - // The shot number should already have been set when the ball was teed up - - // Make a local copy of the results so that we can set the shot_counter - GsResults results = input_results; - results.shot_number_ = shot_counter_; - - if (results.speed_mph_ > 200.0) { - GS_LOG_MSG(warning, "GsSimInterface::SendResultsToGolfSim got out of bounds speed_mph. Settting to 200."); - results.speed_mph_ = 200.0; - } - - bool status = true; - -#ifdef __unix__ // Ignore in Windows environment - - // Loop through any interfaces that we are configured for and send the results - for (auto interface : interfaces_) { - if (interface == nullptr) { - GS_LOG_MSG(error, "GsSimInterface::DeInitializeSims() found a null interface"); - continue; - } - - interface->SendResults(results); - } - -#endif - return status; - } - - bool GsSimInterface::GetAllSystemsArmed() { - bool all_systems_armed = true; - - // Loop through any interfaces that we are configured for and send the results - for (auto interface : interfaces_) { - if (interface == nullptr) { - GS_LOG_MSG(error, "GsSimInterface::DeInitializeSims() found a null interface"); - continue; - } - - // If even one interface is not armed, then we're not "all" ready - if (!interface->GetSimSystemArmed()) { - all_systems_armed = false; - } - } - - return all_systems_armed; - } - - - GsSimInterface* GsSimInterface::GetSimInterfaceByType(GolfSimulatorType sim_type) { - - // Loop through any interfaces that we are configured for and send the results - for (auto interface : interfaces_) { - if (interface == nullptr) { - GS_LOG_MSG(error, "GsSimInterface::DeInitializeSims() found a null interface"); - continue; - } - - if (interface->simulator_type_ == sim_type) { - return interface; - } - } - - return nullptr; - } - - - void GsSimInterface::IncrementShotCounter() { - shot_counter_++; - } - - void GsSimInterface::SendHeartbeat(bool ball_detected) { -#ifdef __unix__ // Ignore in Windows environment - if (!sims_initialized_) { - return; - } - - GsResults heartbeat; - heartbeat.result_message_is_keepalive_ = true; - heartbeat.heartbeat_ball_detected_ = ball_detected; - heartbeat.heartbeat_launch_monitor_ready_ = true; - - for (auto interface : interfaces_) { - if (interface == nullptr) { - continue; - } - interface->SendResults(heartbeat); - } -#endif - } - - bool GsSimInterface::InterfaceIsPresent() { - // The base interface isn't a real interface, so cannot be 'present' - return false; - } - - bool GsSimInterface::Initialize() { - // The base interface isn't a real interface, so cannot be initialized - return false; - } - - void GsSimInterface::DeInitialize() { - // The base interface isn't a real interface, so cannot be de-initializeds - return; - } - - bool GsSimInterface::SendResults(const GsResults& results) { - GS_LOG_TRACE_MSG(trace, "GsSimInterface::SendResults - No Golf Sim connected to Launch Monitor. Results are: " + results.Format()); - return true; - } - - std::string GsSimInterface::GenerateResultsDataToSend(const GsResults& results) { - return results.Format(); - } - - bool GsSimInterface::ProcessReceivedData(const std::string received_data) { - GS_LOG_TRACE_MSG(trace, "GsSimInterface::ProcessReceivedData - No Golf Sim connected to Launch Monitor, so not doing anything with data. Data was:\n" + received_data); - return true; - } - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_sim_interface.h b/Software/LMSourceCode/ImageProcessing/gs_sim_interface.h deleted file mode 100644 index 7e12e237..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_sim_interface.h +++ /dev/null @@ -1,121 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include -#include - - -#include "logging_tools.h" -#include "golf_ball.h" -#include "gs_results.h" - - -// Base class for interfaces to 3rd-party golf simulators - -namespace golf_sim { - - class GsSimInterface { - - public: - enum GolfSimulatorType { - kNone = 0, - kGSPro = 1, - kE6 = 2 - }; - - GsSimInterface(); - virtual ~GsSimInterface(); - - // Create and initialize and sim interfaces that are configured - static bool InitializeSims(); - - // De-initialize and destory and sim interfaces that are configured - static void DeInitializeSims(); - - // Returns true if at least one golf sim is connected to the system. - static bool SimIsConnected(); - - // To be called from the launch monitor - static bool SendResultsToGolfSims(const GsResults& results); - - // If the interface is present (usually indicated in the config.json file), - // this method returns true; - static bool InterfaceIsPresent(); - - // Allows the shot counter to be incremented from outside the simulator - // interface for such purposes and ensuring the counter keeps going even - // when a failure occurs. - - static void IncrementShotCounter(); - - // Will be overridden by each derived class - - virtual bool Initialize(); - - // De-initialize and destroy and sim interfaces that are configured - virtual void DeInitialize(); - - // Base class behavior is to simply print out the JSON - virtual bool SendResults(const GsResults& results); - - // Sends a string without any other side-effects - // Returns the number of bytes written - virtual int SendSimMessage(const std::string& message); - - // Deals with whether or not ALL of the connected simulators are armed - // (ready to take a shot). Some sims just return true. - virtual void SetSimSystemArmed(const bool is_armed); - virtual bool GetSimSystemArmed(); - - // These static functions operate at the collection level for all interfaces - static long GetShotCounter() { return shot_counter_; }; - - // Find the GSPro or E6 or whatever interface (if available) by type - static GsSimInterface *GetSimInterfaceByType(GolfSimulatorType sim_type); - - // Returns true only if each of the available interfaces is armed - static bool GetAllSystemsArmed(); - - // Heartbeat support for external simulators - static void SendHeartbeat(bool ball_detected); - static inline void ResetHeartbeatState() {} - - protected: - - // Typical derived-class behavior will be to convert the results into a - // sim-specific data packet, such as a JSON string - virtual std::string GenerateResultsDataToSend(const GsResults& results); - - // Called when the LM receives data - virtual bool ProcessReceivedData(const std::string received_data); - - protected: - - // Holds pointers to derived interfaces for each attached sim - static std::vector interfaces_; - - static std::string launch_monitor_id_string_; - - // True if all the attached sims have been initialized - static bool sims_initialized_; - - static long shot_counter_; - - // True if all THIS sim has been initialized - bool initialized_; - - GolfSimulatorType simulator_type_; - - // Must be true before the simulator system is ready to accept shot data - // Only relevant for derived, non-virtual classes for whom arming is an - // actual thing. - bool sim_system_is_armed_ = false; - - boost::mutex sim_arming_mutex_; - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.cpp b/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.cpp deleted file mode 100644 index c94d2ea3..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.cpp +++ /dev/null @@ -1,283 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include - -#ifdef __unix__ // Ignore in Windows environment - -#include -#include -#include -#include - -#include "logging_tools.h" -#include "gs_options.h" -#include "gs_config.h" -#include "gs_events.h" -#include "gs_control_msg.h" - -#include "gs_sim_socket_interface.h" -#include "gs_gspro_interface.h" -#include "gs_gspro_response.h" -#include "gs_gspro_results.h" - -using namespace boost::asio; -using ip::tcp; - - -namespace golf_sim { - - GsSimSocketInterface::GsSimSocketInterface() { - } - - GsSimSocketInterface::~GsSimSocketInterface() { - - } - - bool GsSimSocketInterface::InterfaceIsPresent() { - // The socket interface is basically just a base class, so cannot on it's own ber present - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface InterfaceIsPresent should not have been called."); - return false; - } - - bool GsSimSocketInterface::Initialize() { - - // Derived classes must set the socket connection address and port before calling this function - - // Setup the socket connect here first so - // that we don't have to repeatedly do so. May also want to - // setup a keep-alive ping to the SimSocket system. - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface Initialize called."); - - try - { - io_context_ = new boost::asio::io_context(); - - if (io_context_ == nullptr) { - GS_LOG_MSG(error, "GsSimSocketInterface could not create a new io_context."); - return false; - } - - tcp::resolver resolver(*io_context_); - GS_LOG_TRACE_MSG(trace, "Connecting to SimSocketServer at address: " + socket_connect_address_ + ":" + socket_connect_port_); - tcp::resolver::results_type endpoints = resolver.resolve(socket_connect_address_, socket_connect_port_); - - // Create the socket if we haven't done so already - if (socket_ == nullptr) { - socket_ = new tcp::socket(*io_context_); - - if (socket_ == nullptr) { - GS_LOG_MSG(error, "GsSimSocketInterface could not create a new socket."); - return false; - } - } - - - boost::asio::connect(*socket_, endpoints); - - receiver_thread_ = std::unique_ptr(new std::thread(&GsSimSocketInterface::ReceiveSocketData, this)); - - // GS_LOG_TRACE_MSG(trace, "Thread was created. Thread id: " + std::string(receiver_thread_.get()->get_id()) ); - - // socket_->set_option(boost::asio::detail::socket_option::integer{ 10 }); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestSimSocketMessage - Error was: " + std::string(e.what())); - return false; - } - -#ifdef __unix__ // Ignore in Windows environment - // Give the new thread a moment to get running - usleep(500); -#endif - - initialized_ = true; - - // Connection just came up – make sure the first heartbeat reports no ball detected. - GsSimInterface::ResetHeartbeatState(); - GsSimInterface::SendHeartbeat(false); - - // Derived classes will need to deal with any initial messaging after the socket is established. - - return true; - } - - void GsSimSocketInterface::ReceiveSocketData() { - - receive_thread_exited_ = false; - - static std::array buf; - boost::system::error_code error; - std::string received_data_string; - - while (GolfSimGlobals::golf_sim_running_) - { - // We don't want to re-enter this while we're processing - // a received message - // boost::lock_guard lock(sim_socket_receive_mutex_); - - GS_LOG_TRACE_MSG(trace, "Waiting to receive data from SimSocketserver."); - - size_t len = 0; - - try { - // Read_some should be blocking, but if the socket has closed, it will return immediately - len = socket_->read_some(boost::asio::buffer(buf), error); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "GsSimSocketInterface::ReceiveSocketData failed to read from socket - Error was: " + std::string(e.what())); - } - - if (len == 0) { - GS_LOG_MSG(warning, "Received 0-length message from server. Will attempt to re-initialize"); - /// TBD - Are we sure we want to exit? - receive_thread_exited_ = true; - return; - } - - // Null-terminate the string - buf[len] = (char)0; - received_data_string = std::string(buf.data()); - GS_LOG_TRACE_MSG(trace, " Read some data (" + std::to_string(len) + " bytes) : " + received_data_string); - - if (error == boost::asio::error::eof) { - // Connection closed cleanly by peer. - // In this case, we may want to de-initialize - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::ReceiveSocketData Received EOF"); - receive_thread_exited_ = true; - return; - } - else if (error) { - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::ReceiveSocketData Received Error"); - throw boost::system::system_error(error); // Some other error. - } - // Derived classes will, for example, parse the message and inject any - // relevant events into the FSM. - - GS_LOG_TRACE_MSG(trace, "Received SimSocket message of: \n" + received_data_string); - - if (!ProcessReceivedData(received_data_string)) { - GS_LOG_MSG(error, "Failed GsSimSocketInterface::ReceiveSocketData - Could process data: " + received_data_string); - return; - } - } - - GS_LOG_MSG(error, "GsSimSocketInterface::ReceiveSocketData Exiting"); - } - - void GsSimSocketInterface::DeInitialize() { - - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::DeInitialize() called."); - try { - - if (receiver_thread_ != nullptr) { - /*** TBD - Was locking up - GS_LOG_TRACE_MSG(trace, "Waiting for join of receiver_thread_."); - receiver_thread_->join(); - receiver_thread_.release(); - delete receiver_thread_.get(); - */ - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::DeInitialize() killing receive thread."); - -#ifdef __unix__ // Ignore in Windows environment - pthread_cancel(receiver_thread_.get()->native_handle()); -#endif - receiver_thread_ = nullptr; - } - - // TBD - not sure how to deinitialize the TCP socket stuff - delete socket_; - socket_ = nullptr; - delete io_context_; - io_context_ = nullptr; - - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::DeInitialize() completed."); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed GsSimSocketInterface::DeInitialize() - Error was: " + std::string(e.what())); - } - - initialized_ = false; - } - - int GsSimSocketInterface::SendSimMessage(const std::string& message) { - size_t write_length = 0; - boost::system::error_code error; - - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::SendSimMessage - Message was: " + message); - - // We don't want to re-enter this while we're processing - // a received message - boost::lock_guard lock(sim_socket_send_mutex_); - - - try { - - write_length = socket_->write_some(boost::asio::buffer(message), error); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestE6Message - Error was: " + std::string(e.what()) + ". Error code was:" + std::to_string(error.value()) ); - return -2; - } - - return write_length; - } - - - bool GsSimSocketInterface::SendResults(const GsResults& results) { - - if (!initialized_) { - GS_LOG_MSG(error, "GsSimSocketInterface::SendResults called before the interface was intialized."); - return false; - } - - if (receive_thread_exited_) { - GS_LOG_MSG(error, "GsSimSocketInterface::SendResults called before the interface was intialized - trying to re-initialize."); - // If we ended the receive thread, try re-initializing the connection - DeInitialize(); - if (!Initialize()) { - GS_LOG_MSG(error, "GsSimSocketInterface::SendResults could not re-intialize thew interface."); - return false; - } - } - - GS_LOG_TRACE_MSG(trace, "Sending GsSimSocketInterface::SendResult results input message:\n" + results.Format()); - - size_t write_length = -1; - - try { - static std::array buf; - boost::system::error_code error; - - std::string results_msg = GenerateResultsDataToSend(results); - - write_length = SendSimMessage(results_msg); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestSimSocketMessage - Error was: " + std::string(e.what())); - return false; - } - - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::SendResult sent " + std::to_string(write_length) + " bytes."); - - return true; - } - - std::string GsSimSocketInterface::GenerateResultsDataToSend(const GsResults& results) { - return results.Format(); - } - - bool GsSimSocketInterface::ProcessReceivedData(const std::string received_data) { - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::ProcessReceivedData - No Scoket-based Golf Sim connected to Launch Monitor, so not doing anything with data. Data was:\n" + received_data); - return true; - } - -} -#endif diff --git a/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.h b/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.h deleted file mode 100644 index 3abbbcb5..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.h +++ /dev/null @@ -1,69 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include -#include - -#include "gs_results.h" -#include "gs_sim_interface.h" - -using namespace boost::asio; -using ip::tcp; - -// Base class for representing and transferring Golf Sim results over sockets - -namespace golf_sim { - - class GsSimSocketInterface : public GsSimInterface { - - public: - GsSimSocketInterface(); - virtual ~GsSimSocketInterface(); - - // Returns true iff the SimSocket interface is to be used - static bool InterfaceIsPresent(); - - // Must be called before SendResults is called. - virtual bool Initialize(); - - // Deals with, for example, shutting down any socket connection - virtual void DeInitialize(); - - virtual bool SendResults(const GsResults& results); - - virtual void ReceiveSocketData(); - - public: - - std::string socket_connect_address_; - std::string socket_connect_port_; - - protected: - - virtual std::string GenerateResultsDataToSend(const GsResults& results); - - virtual bool ProcessReceivedData(const std::string received_data); - - // Default behavior here is just to send the message to the socket and - // return the number of bytes written - virtual int SendSimMessage(const std::string& message); - - protected: - - tcp::socket* socket_ = nullptr; - boost::asio::io_context* io_context_ = nullptr; - - std::unique_ptr receiver_thread_ = nullptr; - - // TBD - Is this thread safe? - bool receive_thread_exited_ = false; - - boost::mutex sim_socket_receive_mutex_; - boost::mutex sim_socket_send_mutex_; - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index cbe00b3d..22517ee6 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -20,7 +20,6 @@ #include "gs_options.h" #include "gs_clubs.h" #include "gs_ui_system.h" -#include "gs_sim_interface.h" #include "gs_camera.h" #include "gs_http_client.h" #include "gs_events.h" @@ -41,6 +40,7 @@ namespace golf_sim { std::vector GsUISystem::current_shot_image_paths_; std::mutex GsUISystem::shot_images_mutex_; std::atomic GsUISystem::sim_armed_{ true }; + long GsUISystem::shot_counter_ = 0; std::string GsUISystem::CurrentShotRelativePath(const std::string& file_name) { std::lock_guard lock(shot_images_mutex_); @@ -105,6 +105,14 @@ namespace golf_sim { return sim_armed_; } + void GsUISystem::IncrementShotCounter() { + shot_counter_++; + } + + long GsUISystem::GetShotCounter() { + return shot_counter_; + } + void GsUISystem::PostResult(const std::string& json) { std::string reply = GsHttpClient::PostResult(json); @@ -245,7 +253,7 @@ namespace golf_sim { std::string msg = "Ball Hit - Results returned." + secondary_message; - GS_LOG_MSG(info, "BALL_HIT_CSV, " + std::to_string(GsSimInterface::GetShotCounter()) + GS_LOG_MSG(info, "BALL_HIT_CSV, " + std::to_string(GetShotCounter()) + ", (carry - NA), (Total - NA), (Side Dest - NA), (Smash Factor - NA), (Club Speed - NA), " + std::to_string(CvUtils::MetersPerSecondToMPH(speed)) + ", " + std::to_string(back_spin) + ", " @@ -274,7 +282,7 @@ namespace golf_sim { std::string file_name(input_file_name); if (GolfSimCamera::kLogDiagnosticImagesToUniqueFiles && !suppress_diagnostic_saving) { - LoggingTools::LogImage(file_name + "_", img, std::vector < cv::Point >{}, false, "", "_Shot_" + std::to_string(GsSimInterface::GetShotCounter())); + LoggingTools::LogImage(file_name + "_", img, std::vector < cv::Point >{}, false, "", "_Shot_" + std::to_string(GetShotCounter())); } if (!GolfSimCamera::kLogWebserverImagesToFile) { diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h index b3e0ad90..1d3067ab 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h @@ -60,6 +60,10 @@ namespace golf_sim { // Whether the web server's sims are ready for a shot, as of its last reply. static bool SimArmed(); + // Numbers each shot for the log lines and image names. + static void IncrementShotCounter(); + static long GetShotCounter(); + // Save the image into the shared web-server directory so that the web-based // golf-sim user interface can access it. // Also save a uniquely-named copy to the usual images directory unless suppressed. @@ -74,6 +78,8 @@ namespace golf_sim { static void PostResult(const std::string& json); static std::atomic sim_armed_; + + static long shot_counter_; }; } diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 3338bc6b..491adb3a 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -19,7 +19,6 @@ #include "gs_globals.h" #include "golf_ball.h" #include "gs_options.h" -#include "gs_gspro_results.h" #include "gs_ui_system.h" #include "gs_config.h" #include "gs_results.h" @@ -34,11 +33,6 @@ #include "pulse_strobe.h" -#include "gs_gspro_results.h" -#include "gs_gspro_test_server.h" -#include "gs_gspro_response.h" -#include "gs_sim_interface.h" -#include "gs_e6_interface.h" #include "gs_calibration.h" #include "gs_automated_testing.h" @@ -522,7 +516,7 @@ bool testAnalyzeStrobedBalls() { #ifdef __unix__ // Ignore in Windows environment GsUISystem::SaveWebserverImage("kCameraXBallLocation_", exposures_image, exposure_balls); #endif - GsGSProResults results(result_ball); + GsResults results(result_ball); GS_LOG_TRACE_MSG(trace, "Results are: " + results.Format()); PulseStrobe::DeinitGPIOSystem(); @@ -706,100 +700,6 @@ bool test_hit_trigger() { return true; } -void WaitForSimArmed() { - - // Wait until the system is armed. - while (true) { - if (GsSimInterface::GetAllSystemsArmed()) - break; - - GS_LOG_TRACE_MSG(info, "Waiting for interface armed..."); -#ifdef __unix__ - sleep(1); -#endif - } -} - -bool WaitAndSendShotToSim(int shot_number, GsGSProResults& test_result) { - try { - GS_LOG_TRACE_MSG(trace, "Sending test shot " + std::to_string(shot_number)); - - if (!GsSimInterface::SendResultsToGolfSims(test_result)) { - GS_LOG_MSG(error, "Failed to SendResultsToGolfSim (the Golf Simulator Interface)."); - return false; - } - - GS_LOG_TRACE_MSG(trace, "Sent test shot " + std::to_string(shot_number)); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestGSProServer - Error was: " + std::string(e.what())); - return false; - } - - return true; -} - -bool TestExternalSimMessage() { - - if (!GsSimInterface::InitializeSims()) { - GS_LOG_MSG(error, "Failed to Initialize the Golf Simulator Interface."); - return false; - } - -#ifdef __unix__ - // Brief settle time for simulator connections - sleep(2); -#endif - GolfBall ball; - ball.velocity_ = 123.6; - GsGSProResults test_result(ball); - test_result.speed_mph_ = 99; - test_result.vla_deg_ = 23.4F; - test_result.hla_deg_ = 1.23F; - test_result.back_spin_rpm_ = 3456; - test_result.side_spin_rpm_ = -567; - -#ifdef __unix__ - - // If we are interfacing with a TruGolf/E6 system, then we need to make sure that it is armed before - // sending shot information. For GSPro, the arming is not important. - - if (GsE6Interface::InterfaceIsPresent()) { - GS_LOG_TRACE_MSG(trace, "Waiting for E6 simulator arm message."); - sleep(2); - } - else { - sleep(1); - } -#endif - GsSimInterface::IncrementShotCounter(); - - WaitForSimArmed(); - - if (!WaitAndSendShotToSim(GsSimInterface::GetShotCounter(), test_result)) { - GS_LOG_MSG(error, "Failed to WaitAndSendShotToSim (the Golf Simulator Interface)."); - } - - test_result.speed_mph_ = 55; - test_result.vla_deg_ = 12.3F; - - GsSimInterface::IncrementShotCounter(); - - WaitForSimArmed(); - - if (!WaitAndSendShotToSim(GsSimInterface::GetShotCounter(), test_result)) { - GS_LOG_MSG(error, "Failed to WaitAndSendShotToSim (the Golf Simulator Interface)."); - - } - return true; - - GS_LOG_TRACE_MSG(trace, "De-initializing GSPro interface."); - GsSimInterface::DeInitializeSims(); - - return true; -} - bool TestBallDeltaCalculations() { // Setup a couple of test balls in specific locations. Each ball needs the same information it would have if // the GolfSimCamera::ComputeXyzDistanceFromOrthoCamPerspective function had been called on it @@ -877,36 +777,6 @@ bool TestBallDeltaCalculations() { } -bool TestGSProServer() { - try - { - int kGSProConnectPort; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", kGSProConnectPort); - - boost::asio::io_context io_context; - GsGSProTestServer server(io_context, kGSProConnectPort); - GS_LOG_TRACE_MSG(trace, "About to call io_context.run()"); - io_context.run(); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestGSProServer - Error was: " + std::string(e.what())); - return false; - } - - return true; -} - -void test_gspro_communication() { - - GolfBall ball; - ball.rotation_speeds_RPM_[2] = 5000.; - ball.rotation_speeds_RPM_[0] = 100.; - GsGSProResults results(ball); - std::string json = results.Format(); - GS_LOG_MSG(debug, json); -} - static std::atomic g_shutdown_requested(false); void signal_handler(int sig) { @@ -1000,22 +870,19 @@ void run_main(int argc, char* argv[]) sleep(kInterShotInjectionPauseSeconds); if (!GolfSimOptions::GetCommandLineOptions().skip_wait_armed_) { - while(!GsSimInterface::GetAllSystemsArmed()) { + // The armed state only changes on a reply, so ask again each time around + while(!GsUISystem::SimArmed()) { sleep(2); GS_LOG_MSG(info, " Waiting for Simulator to Arm."); + GsUISystem::SendIPCStatusMessage(GsIPCResultType::kWaitingForSimulatorArmed); } } else { GS_LOG_MSG(info, " Skipping wait for simulator armed (hardware-less testing mode)."); } - GsSimInterface::IncrementShotCounter(); - - // Get the result to the golf simulator ASAP - if (!GsSimInterface::SendResultsToGolfSims(result)) { - GS_LOG_MSG(error, "Could not SendResultsToGolfSim. Continuing"); - } + GsUISystem::IncrementShotCounter(); - // Also send to Python webserver via IPC for monitoring + // The web server passes the shot on to every connected sim GolfBall test_ball; test_ball.velocity_ = result.speed_mph_ * 0.44704; // Convert mph to m/s test_ball.angles_ball_perspective_[0] = result.hla_deg_; @@ -1127,9 +994,6 @@ void run_main(int argc, char* argv[]) return; } - std::string address; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", address); - if (kStartInPuttingMode) { GS_LOG_MSG(info, "Starting in Putting Mode."); GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kPutter); @@ -1208,24 +1072,6 @@ void run_main(int argc, char* argv[]) } break; - case SystemMode::kTestExternalSimMessage: - { - if (!TestExternalSimMessage()) { - GS_LOG_MSG(info, "Failed to TestExternalSimMessage."); - return; - } - } - break; - - case SystemMode::kTestGSProServer: - { - if (!TestGSProServer()) { - GS_LOG_MSG(info, "Failed to TestGSProSever."); - return; - } - } - break; - case SystemMode::kAutomatedTesting: { if (!GsAutomatedTesting::TestBallPosition()) { @@ -1319,10 +1165,6 @@ void run_main(int argc, char* argv[]) } #else - // TBD - REMOVE -Just for testing - std::string address; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", address); - if (kStartInPuttingMode) { GS_LOG_MSG(info, "Starting in Putting Mode."); GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kPutter); @@ -1418,7 +1260,6 @@ void run_main(int argc, char* argv[]) testAnalyzeStrobedBalls(); // test_strobed_balls_detection(); // TestBallPosition(); - // test_gspro_communication(); // testSpinDetection(); break; } @@ -1506,8 +1347,6 @@ int main(int argc, char *argv[]) ", wait_keys=" + std::to_string(options.wait_for_key_on_images_) + ", practice_ball=" + std::to_string(options.practice_ball_) + ", search_center=" + std::to_string(options.search_center_x_) + "," + std::to_string(options.search_center_y_) + - ", gspro_host_address=" + options.gspro_host_address_ + - ", e6_host_address=" + options.e6_host_address_ + ", slot1 type/lens/orientation=" + std::to_string(GolfSimCamera::kSystemSlot1CameraType) + "/" + std::to_string(GolfSimCamera::kSystemSlot1LensType) + "/" + std::to_string(GolfSimCamera::kSystemSlot1CameraOrientation) + ", slot2 type/lens/orientation=" + std::to_string(GolfSimCamera::kSystemSlot2CameraType) + "/" + diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index 35373f78..d6eb9a9f 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -147,29 +147,14 @@ if enable_recompile_closed_source message('Recompiling closed source code') run_command('cp', '/home/USERNAME/Dev/ClosedSourceCode/gs_e6_response.cpp.FULL', 'gs_e6_response.cpp', check:true) run_command('cp', '/home/USERNAME/Dev/ClosedSourceCode/obfuscate.h', 'obfuscate.h', check:true) - pitrac_lm_sources += ([ 'gs_e6_response.cpp' ]) e6_auth_sources += ([ 'gs_e6_response.cpp' ]) e6_auth_deps += ([ opencv_dep.partial_dependency(compile_args : true, includes : true) ]) endif -# Move the pre-compiled, closed-source object file(s) into place -# This is a workaround to the fact that meson will not allow paths in the -# output and input parameters of a custom_target -create_closed_source_objects = find_program('create_closed_source_objects.sh') -closed_source_target = custom_target('copy_closed_source_object', - input : 'gs_e6_results.cpp', - output : 'gs_e6_response.cpp.o', - command : [ create_closed_source_objects, '@INPUT@', '@OUTPUT@'], -) - if not enable_recompile_closed_source message('Using pre-compiled closed object_code') - pitrac_lm_sources += ([ closed_source_target, ]) - closed_source_object_files += (['gs_e6_response.cpp.o']) -endif - -if not enable_recompile_closed_source r = run_command ( 'cp', '-f', './ClosedSourceObjectFiles/gs_e6_response.cpp.o', '.', check:true) + closed_source_object_files += (['gs_e6_response.cpp.o']) endif libav_dep_names = ['libavcodec', 'libavdevice', 'libavformat', 'libavutil', 'libswresample'] @@ -204,20 +189,12 @@ pitrac_lm_sources += ([ 'gs_options.cpp', 'gs_config.cpp', 'gs_shot_parameters.cpp', - 'gs_sim_interface.cpp', - 'gs_gspro_interface.cpp', - 'gs_gspro_response.cpp', - 'gs_gspro_test_server.cpp', - 'gs_sim_socket_interface.cpp', - 'gs_e6_interface.cpp', - 'gs_e6_results.cpp', 'logging_tools.cpp', 'gs_events.cpp', 'worker_thread.cpp', 'camera_hardware.cpp', 'gs_control_msg.cpp', 'gs_results.cpp', - 'gs_gspro_results.cpp', 'gs_ui_system.cpp', 'gs_result_types.cpp', 'gs_http_client.cpp', @@ -243,7 +220,6 @@ exec = executable('pitrac_lm', pitrac_lm_sources, include_directories : pitrac_lm_include_dirs, install : true, - objects : closed_source_object_files, dependencies : pitrac_lm_module_deps, cpp_args : cpp_arguments ) diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 1382b512..da061716 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -638,50 +638,6 @@ "default": 0.0, "requiresRestart": false }, - "gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress": { - "category": "Simulators", - "subcategory": "basic", - "basicSubcategory": "Simulators", - "displayName": "E6 Connect Address", - "description": "IP address of E6 Connect simulator", - "type": "ip_address", - "default": "", - "requiresRestart": false - }, - "gs_config.golf_simulator_interfaces.E6.kE6ConnectPort": { - "category": "Simulators", - "subcategory": "basic", - "basicSubcategory": "Simulators", - "displayName": "E6 Connect Port", - "description": "Port number for E6 Connect simulator", - "type": "number", - "min": 1, - "max": 65535, - "default": 2483, - "requiresRestart": true - }, - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress": { - "category": "Simulators", - "subcategory": "basic", - "basicSubcategory": "Simulators", - "displayName": "GSPro Address", - "description": "IP address of GSPro simulator (e.g., 192.168.1.100)", - "type": "ip_address", - "default": "", - "requiresRestart": false - }, - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort": { - "category": "Simulators", - "subcategory": "basic", - "basicSubcategory": "Simulators", - "displayName": "GSPro Port", - "description": "Port number for GSPro simulator", - "type": "integer", - "min": 1, - "max": 65535, - "default": 921, - "requiresRestart": true - }, "simulators.ogs.enabled": { "category": "Simulators", "subcategory": "basic", @@ -1659,26 +1615,6 @@ "default": 40000, "requiresRestart": true }, - "gs_config.golf_simulator_interfaces.E6.kE6InterMessageDelayMs": { - "category": "Simulators", - "subcategory": "advanced", - "displayName": "E6 Inter-Message Delay (ms)", - "description": "Delay between E6 messages in milliseconds", - "type": "integer", - "min": 0, - "max": 500, - "default": 50, - "requiresRestart": false - }, - "gs_config.golf_simulator_interfaces.kLaunchMonitorIdString": { - "category": "Simulators", - "subcategory": "advanced", - "displayName": "Launch Monitor ID String", - "description": "Identification string sent to golf simulators", - "type": "text", - "default": "PiTrac LM 0.1", - "requiresRestart": false - }, "gs_config.club_data.kEnableClubImages": { "category": "Club Data", "subcategory": "advanced", diff --git a/Software/web-server/sims/e6_sim.py b/Software/web-server/sims/e6_sim.py index 6345e821..c3615041 100644 --- a/Software/web-server/sims/e6_sim.py +++ b/Software/web-server/sims/e6_sim.py @@ -16,7 +16,7 @@ def build_ball_data(shot: ShotData) -> Dict[str, object]: - # The ranges E6 enforces, which GsE6Results::Format meant to apply + # The ranges E6 enforces ball = { "BackSpin": clamp(float(shot.back_spin), -999, 19999), "BallSpeed": clamp(float(shot.speed), 0.09, 249.9), diff --git a/Software/web-server/sims/gspro_sim.py b/Software/web-server/sims/gspro_sim.py index 6c0becbb..cc04580c 100644 --- a/Software/web-server/sims/gspro_sim.py +++ b/Software/web-server/sims/gspro_sim.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) _MAX_SPEED_MPH = 200.0 -# Process-wide like GsSimInterface::shot_counter_, so numbers survive reconnects and sim rebuilds +# Process-wide, so numbers survive reconnects and sim rebuilds _shot_numbers = itertools.count(1) _ZERO_CLUB_DATA = { key: 0.0 @@ -119,7 +119,7 @@ async def _on_message(self, obj: object, raw: str = "") -> None: return if code == 201: player = obj.get("Player") or {} - # GsGSProResponse treats any club but PT as the driver + # Any club but PT counts as the driver club = "putter" if player.get("Club") == "PT" else "driver" logger.info(f"GSPro player info: club {player.get('Club')!r}, handed {player.get('Handed')!r}") if self._on_club is not None: diff --git a/Software/web-server/templates/testing.html b/Software/web-server/templates/testing.html index b4ea089a..6624523a 100644 --- a/Software/web-server/templates/testing.html +++ b/Software/web-server/templates/testing.html @@ -68,13 +68,6 @@

              Testing Suite
              - -
              -
              -

              Connectivity Tests

              -
              -
              -
              diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index fa8bceb7..d8edde26 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -97,14 +97,6 @@ def __init__(self, config_manager): "requires_sudo": False, "timeout": 120, }, - "test_gspro_server": { - "name": "Test GSPro Server", - "description": "Test GSPro server connectivity", - "category": "connectivity", - "args": ["--system_mode", "test_gspro_server"], - "requires_sudo": False, - "timeout": 30, - }, } def get_available_tools(self) -> Dict[str, Any]: diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index f512e013..708a14e1 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -83,17 +83,13 @@ def test_config_validation_gain(self, config_manager): def test_config_validation_port(self, config_manager): """Test configuration validation for network ports""" - is_valid, _ = config_manager.validate_config("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", "0") + is_valid, _ = config_manager.validate_config("simulators.gspro.port", "0") assert not is_valid - is_valid, _ = config_manager.validate_config( - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", "70000" - ) + is_valid, _ = config_manager.validate_config("simulators.gspro.port", "70000") assert not is_valid - is_valid, _ = config_manager.validate_config( - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", "8080" - ) + is_valid, _ = config_manager.validate_config("simulators.gspro.port", "8080") assert is_valid def test_reset_all(self, setup_config_files): @@ -159,19 +155,13 @@ def test_missing_file_handling(self, config_manager): def test_nested_config_access(self, config_manager): """Test accessing deeply nested configuration values""" - config_manager.user_settings = { - "gs_config": { - "golf_simulator_interfaces": { - "GSPro": {"kGSProConnectAddress": "192.168.1.100", "kGSProConnectPort": 921} - } - } - } + config_manager.user_settings = {"simulators": {"gspro": {"host": "192.168.1.100", "port": 921}}} config_manager._rebuild_merged_config() - port = config_manager.get_config("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort") + port = config_manager.get_config("simulators.gspro.port") assert port == 921 - address = config_manager.get_config("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress") + address = config_manager.get_config("simulators.gspro.host") assert address == "192.168.1.100" def test_set_survives_reload(self, setup_config_files): diff --git a/Software/web-server/tests/test_pitrac_manager.py b/Software/web-server/tests/test_pitrac_manager.py index a6964fe0..2ae48c32 100644 --- a/Software/web-server/tests/test_pitrac_manager.py +++ b/Software/web-server/tests/test_pitrac_manager.py @@ -22,8 +22,8 @@ def mock_config_manager(self): "logging.level": "info", "storage.image_dir": "/var/pitrac/images", "storage.web_share_dir": "/var/pitrac/web", - "gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress": "192.168.1.100", - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress": "192.168.1.101", + "simulators.e6.host": "192.168.1.100", + "simulators.gspro.host": "192.168.1.101", "gs_config.cameras.kCamera1Gain": 1.0, "gs_config.cameras.kCamera2Gain": 4.0, } diff --git a/Software/web-server/tests/utils/mock_factories.py b/Software/web-server/tests/utils/mock_factories.py index 99294318..aac49e3e 100644 --- a/Software/web-server/tests/utils/mock_factories.py +++ b/Software/web-server/tests/utils/mock_factories.py @@ -18,7 +18,7 @@ def create_basic_config_manager() -> Mock: manager.get_config.return_value = { "cameras": {"camera1_gain": 2.0, "camera2_gain": 4.0}, - "simulators": {"gspro_host": "192.168.1.100", "gspro_port": 921}, + "simulators": {"gspro": {"host": "192.168.1.100", "port": 921}}, "logging": {"level": "info"}, } @@ -27,7 +27,7 @@ def create_basic_config_manager() -> Mock: manager.get_categories.return_value = { "Basic": {"basic": [], "advanced": []}, "Cameras": {"basic": ["cameras.camera1_gain"], "advanced": ["cameras.camera2_gain"]}, - "Simulators": {"basic": ["simulators.gspro_host"], "advanced": ["simulators.gspro_port"]}, + "Simulators": {"basic": ["simulators.gspro.host"], "advanced": ["simulators.gspro.port"]}, } manager.set_config.return_value = (True, "Configuration updated", False) @@ -46,8 +46,8 @@ def create_pitrac_config_manager() -> Mock: "logging.level": "info", "storage.image_dir": "/var/pitrac/images", "storage.web_share_dir": "/var/pitrac/web", - "gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress": "192.168.1.100", - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress": "192.168.1.101", + "simulators.e6.host": "192.168.1.100", + "simulators.gspro.host": "192.168.1.101", "gs_config.cameras.kCamera1Gain": 1.0, "gs_config.cameras.kCamera2Gain": 4.0, } From 4558c87713734390c378b22742a49a42770ffc20 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 1 Sep 2026 16:53:23 -0400 Subject: [PATCH 113/156] use the shared ball image processor in lm_main instead of leaking a new one per call --- Software/LMSourceCode/ImageProcessing/lm_main.cpp | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 491adb3a..ed54ccb9 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -54,13 +54,6 @@ static constexpr std::string_view TEST_IMAGE_PREFIX = "TEST_RESULT_GetBall_"; std::string kBaseTestDir = "Will be set from the .json configuration file"; -BallImageProc *get_ball_image_processor() { - BallImageProc *ip = new BallImageProc; - // TBD - Setup as necessary - return ip; -} - - void test_image(std::string_view subdir, std::string_view filename) { BOOST_LOG_FUNCTION(); @@ -101,7 +94,7 @@ void test_image(std::string_view subdir, std::string_view filename) { } } - BallImageProc *ip = get_ball_image_processor(); + BallImageProc *ip = BallImageProc::get_ball_image_processor(); cv::Mat img = cv::imread(fname, cv::IMREAD_COLOR); ip->image_name_ = fname; @@ -545,7 +538,7 @@ bool test_strobed_balls_detection() { return false; } - BallImageProc *ip = get_ball_image_processor(); + BallImageProc *ip = BallImageProc::get_ball_image_processor(); ip->image_name_ = kBaseTestDir + kCam1BallOnTee; From 5ccd59133c41a79f7e54f08f201fb45ce20b85a9 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 1 Sep 2026 18:49:22 -0400 Subject: [PATCH 114/156] drop the send_test_results shot injection mode, nothing can feed it shots --- .../ImageProcessing/gs_config.cpp | 33 ----------- .../LMSourceCode/ImageProcessing/gs_config.h | 2 - .../ImageProcessing/gs_options.cpp | 1 - .../LMSourceCode/ImageProcessing/gs_options.h | 6 -- .../LMSourceCode/ImageProcessing/lm_main.cpp | 59 ------------------- Software/web-server/configurations.json | 9 --- Software/web-server/testing_tools_manager.py | 2 +- Software/web-server/tests/test_config_keys.py | 8 +-- 8 files changed, 2 insertions(+), 118 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index b1fca62e..41fd2df3 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -113,39 +113,6 @@ static void PopulateOptionsFromConfig() { } - bool GolfSimConfiguration::ReadShotInjectionData(std::vector& shots, - int & kInterShotInjectionPauseSeconds) { - try { - SetConstant("gs_config.testing.kInterShotInjectionPauseSeconds", kInterShotInjectionPauseSeconds); - - // Retrirve as many shots as are defined in the json file - boost::property_tree::ptree shots_json = configuration_root_.get_child("gs_config.testing.test_shots_to_inject"); - - int shot_number = 1; - for (boost::property_tree::ptree::iterator iter = shots_json.begin(); iter != shots_json.end(); iter++) { - // for (boost::property_tree::ptree& shot_section : shots_json) { - GsResults result; - result.shot_number_ = shot_number; - shot_number++; - - result.speed_mph_ = iter->second.get("Speed", 0); - result.hla_deg_ = iter->second.get("HLA", 0); - result.vla_deg_ = iter->second.get("VLA", 0); - result.back_spin_rpm_ = iter->second.get("BackSpin", 0); - result.side_spin_rpm_ = iter->second.get("SideSpin", 0); - result.club_type_ = GolfSimClubs::GsClubType::kNotSelected; - - shots.push_back(result); - } - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::ReadShotInjectionData failed. ERROR: *** " + std::string(e.what()) + " ***"); - return false; - } - return true; - } - // Returns the valiue of the environment variable PITRAC_ROOT std::string GolfSimConfiguration::GetPiTracRootPath() { diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.h b/Software/LMSourceCode/ImageProcessing/gs_config.h index e6fcdbb5..cd102132 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.h +++ b/Software/LMSourceCode/ImageProcessing/gs_config.h @@ -71,8 +71,6 @@ namespace golf_sim { static void SetConstant(const std::string& tag_name, cv::Mat& matrix); - static bool ReadShotInjectionData(std::vector& shots, - int& kInterShotInjectionPauseSeconds); // Returns the valiue of the environment variable PITRAC_ROOT static std::string GetPiTracRootPath(); diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.cpp b/Software/LMSourceCode/ImageProcessing/gs_options.cpp index 6ca72fdb..b19e2215 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_options.cpp @@ -144,7 +144,6 @@ void GolfSimOptions::Print() const std::cout << " shutdown: " << std::to_string(shutdown_) << std::endl; std::cout << " cam_still_mode: " << std::to_string(camera_still_mode_) << std::endl; std::cout << " lm_comparison_mode: " << std::to_string(lm_comparison_mode_) << std::endl; - std::cout << " send_test_results: " << std::to_string(send_test_results_) << std::endl; if (!output_filename_.empty()) std::cout << " output_filename: " << output_filename_ << std::endl; if (!base_image_logging_dir_.empty()) diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.h b/Software/LMSourceCode/ImageProcessing/gs_options.h index 9fe15beb..a28eb376 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.h +++ b/Software/LMSourceCode/ImageProcessing/gs_options.h @@ -76,10 +76,6 @@ namespace golf_sim { "Take a single camera2 still picture (using one strobe flash) and exit") ("lm_comparison_mode", value(&lm_comparison_mode_)->default_value(false)->implicit_value(true), "Configure for operating in another infrared-based LM environment") - ("send_test_results", value(&send_test_results_)->default_value(false)->implicit_value(true), - "Send test shot results to the web server and exit") - ("skip_wait_armed", value(&skip_wait_armed_)->default_value(false)->implicit_value(true), - "Skip waiting for simulator armed state (for hardware-less testing)") ("output_filename", value(&output_filename_)->default_value("out.png"), "Write any still picture to the specified filename") ("pulse_test", value(&perform_pulse_test_)->default_value(false)->implicit_value(true), @@ -121,8 +117,6 @@ namespace golf_sim { bool shutdown_; bool camera_still_mode_; bool lm_comparison_mode_; - bool send_test_results_; - bool skip_wait_armed_; bool practice_ball_ = false; bool perform_pulse_test_; bool use_non_IR_camera_; diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index ed54ccb9..315be8b4 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -835,65 +835,6 @@ void run_main(int argc, char* argv[]) - if (GolfSimOptions::GetCommandLineOptions().send_test_results_) { - - GS_LOG_TRACE_MSG(trace, "Running in send_test_results mode."); - if (!PerformSystemStartupTasks()) { - GS_LOG_MSG(error, "Failed to PerformSystemStartupTasks."); - return; - } - - // Send as many test shots as we have to whatever golf sim we are connected to - std::vector shots; - - int kInterShotInjectionPauseSeconds = 0; - if (!GolfSimConfiguration::ReadShotInjectionData(shots, kInterShotInjectionPauseSeconds)) { - GS_LOG_MSG(error, "Failed to kInterShotInjectionPauseSeconds."); - return; - } - else { - GS_LOG_MSG(info, "About to inject " + std::to_string(shots.size()) + " shots."); - } - - for (GsResults& result : shots) { - GS_LOG_MSG(info, "******** READY FOR SHOT NO. " + std::to_string(result.shot_number_) + " ********"); - - GS_LOG_MSG(info, "******** PLEASE RE-ARM THE SIMULATOR TO ACCEPT ANOTHER SHOT ********"); - - sleep(kInterShotInjectionPauseSeconds); - - if (!GolfSimOptions::GetCommandLineOptions().skip_wait_armed_) { - // The armed state only changes on a reply, so ask again each time around - while(!GsUISystem::SimArmed()) { - sleep(2); - GS_LOG_MSG(info, " Waiting for Simulator to Arm."); - GsUISystem::SendIPCStatusMessage(GsIPCResultType::kWaitingForSimulatorArmed); - } - } else { - GS_LOG_MSG(info, " Skipping wait for simulator armed (hardware-less testing mode)."); - } - - GsUISystem::IncrementShotCounter(); - - // The web server passes the shot on to every connected sim - GolfBall test_ball; - test_ball.velocity_ = result.speed_mph_ * 0.44704; // Convert mph to m/s - test_ball.angles_ball_perspective_[0] = result.hla_deg_; - test_ball.angles_ball_perspective_[1] = result.vla_deg_; - test_ball.rotation_speeds_RPM_[0] = result.side_spin_rpm_; - test_ball.rotation_speeds_RPM_[2] = result.back_spin_rpm_; - - std::string message = "Test shot #" + std::to_string(result.shot_number_); - GsUISystem::SendIPCHitMessage(test_ball, message); - } - - - PerformSystemShutdownTasks(); - - return; - } - - // In this mode, we just take a single picture and save it. if (GolfSimOptions::GetCommandLineOptions().camera_still_mode_) { diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index da061716..468b8201 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -3445,15 +3445,6 @@ "default": true, "requiresRestart": true }, - "gs_config.testing.kInterShotInjectionPauseSeconds": { - "category": "testing", - "subcategory": "advanced", - "displayName": "Inter-Shot Injection Pause (s)", - "description": "Seconds between injected test shots", - "type": "integer", - "default": 0, - "requiresRestart": true - }, "gs_config.testing.kTwoImageTestPreImage": { "category": "testing", "subcategory": "advanced", diff --git a/Software/web-server/testing_tools_manager.py b/Software/web-server/testing_tools_manager.py index d8edde26..934bc3ec 100644 --- a/Software/web-server/testing_tools_manager.py +++ b/Software/web-server/testing_tools_manager.py @@ -35,7 +35,7 @@ def __init__(self, config_manager): "name": "Test Uploaded Image", "description": "Run full pipeline on uploaded flight camera image", "category": "testing", - "args": ["--system_mode", "test", "--skip_wait_armed"], + "args": ["--system_mode", "test"], "requires_sudo": False, "timeout": 60, "uses_uploaded_image": True, diff --git a/Software/web-server/tests/test_config_keys.py b/Software/web-server/tests/test_config_keys.py index 019fabb2..8c6c48c4 100644 --- a/Software/web-server/tests/test_config_keys.py +++ b/Software/web-server/tests/test_config_keys.py @@ -14,12 +14,6 @@ KEY_LITERAL = re.compile(r'(? 200 settings = json.loads(SCHEMA.read_text())["settings"] - missing = sorted(keys - settings.keys() - NOT_IN_SCHEMA) + missing = sorted(keys - settings.keys()) assert missing == [] From 8890e1d30c46102642ca56097c806cdb8445beb1 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Tue, 1 Sep 2026 21:59:36 -0400 Subject: [PATCH 115/156] stop bad legacy or hand-edited sim settings from blocking web server startup --- Software/web-server/config_manager.py | 5 +- Software/web-server/sim_manager.py | 63 ++++++++++--------- .../web-server/tests/test_config_manager.py | 15 +++++ Software/web-server/tests/test_sim_manager.py | 13 ++++ 4 files changed, 67 insertions(+), 29 deletions(-) diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index cdf098c5..5b5940fa 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -248,12 +248,15 @@ def _move_renamed_keys(self) -> None: meta = schema.get(new, {}) if meta.get("type") == "integer": value = _as_integer(value) + if not isinstance(value, int): + logger.warning(f"Dropping stored setting {old}={value!r}, {new} needs an integer") + continue if value == meta.get("default"): logger.info(f"Dropping stored setting {old}={value!r}, it equals the default of {new}") continue moved[new] = value logger.info(f"Moved stored setting {old} to {new}") - if new.endswith(".host") and str(value).strip(): + if new.endswith(".host") and isinstance(value, str) and value.strip(): sim = new.rsplit(".", 1)[0] moved.setdefault(f"{sim}.enabled", True) moved.setdefault(f"{sim}.auto_connect", True) diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index f9653623..79ec2a3d 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -30,6 +30,7 @@ def __init__(self, config_manager, broadcast: Optional[BroadcastFn] = None) -> N self.config_manager = config_manager self._broadcast = broadcast self._sims: Dict[str, SimInterface] = {} + self._build_errors: Dict[str, Dict[str, str]] = {} self.loop: Optional[asyncio.AbstractEventLoop] = None self._reload_task: Optional[asyncio.Task] = None self._ball_detected: Optional[bool] = None @@ -39,34 +40,40 @@ def __init__(self, config_manager, broadcast: Optional[BroadcastFn] = None) -> N def build_sims(self) -> None: get = self.config_manager.get_config - sims: List[SimInterface] = [] - if get("simulators.ogs.enabled"): - sims.append( - OGSSim( - host=get("simulators.ogs.host") or "", - port=int(get("simulators.ogs.port") or 3111), - keepalive_sec=int(get("simulators.ogs.keepalive_sec") or 5), - ) - ) - if get("simulators.gspro.enabled"): - sims.append( - GSProSim( - host=get("simulators.gspro.host") or "", - port=int(get("simulators.gspro.port") or 921), - on_club=functools.partial(self.set_club, "gspro"), - ) - ) - if get("simulators.e6.enabled"): - sims.append( - E6Sim( - host=get("simulators.e6.host") or "", - port=int(get("simulators.e6.port") or 2483), - inter_message_delay_ms=int(get("simulators.e6.inter_message_delay_ms") or 0), - on_club=functools.partial(self.set_club, "e6"), - ) - ) + factories = { + OGSSim: lambda: OGSSim( + host=get("simulators.ogs.host") or "", + port=int(get("simulators.ogs.port") or 3111), + keepalive_sec=int(get("simulators.ogs.keepalive_sec") or 5), + ), + GSProSim: lambda: GSProSim( + host=get("simulators.gspro.host") or "", + port=int(get("simulators.gspro.port") or 921), + on_club=functools.partial(self.set_club, "gspro"), + ), + E6Sim: lambda: E6Sim( + host=get("simulators.e6.host") or "", + port=int(get("simulators.e6.port") or 2483), + inter_message_delay_ms=int(get("simulators.e6.inter_message_delay_ms") or 0), + on_club=functools.partial(self.set_club, "e6"), + ), + } self._sims = {} - for sim in sims: + self._build_errors = {} + for cls, factory in factories.items(): + if not get(f"simulators.{cls.name}.enabled"): + continue + try: + sim = factory() + except (TypeError, ValueError, OverflowError) as e: + logger.error(f"{cls.display_name} settings are invalid, sim not started: {e}") + self._build_errors[cls.name] = { + "name": cls.name, + "display_name": cls.display_name, + "status": "error", + "detail": f"Invalid settings: {e}", + } + continue sim.set_status_callback(functools.partial(self._on_sim_status, sim)) self._sims[sim.name] = sim @@ -155,7 +162,7 @@ async def disconnect(self, name: str) -> None: await sim.disconnect() def status(self) -> List[Dict[str, str]]: - return [sim.info() for sim in self._sims.values()] + return [sim.info() for sim in self._sims.values()] + list(self._build_errors.values()) async def _on_sim_status(self, sim: SimInterface) -> None: if sim.name == self._club_sim and sim.status != "connected": diff --git a/Software/web-server/tests/test_config_manager.py b/Software/web-server/tests/test_config_manager.py index 708a14e1..f96f06d9 100644 --- a/Software/web-server/tests/test_config_manager.py +++ b/Software/web-server/tests/test_config_manager.py @@ -415,6 +415,21 @@ def test_values_equal_to_the_default_leave_no_row(self, db): assert rows == {} + def test_integer_value_that_cannot_be_coerced_is_dropped(self, db): + rows = self._start(db, { + f"{OLD_SIM}.GSPro.kGSProConnectPort": "abc", + f"{OLD_SIM}.E6.kE6ConnectPort": "inf", + f"{OLD_SIM}.E6.kE6InterMessageDelayMs": None, + }) + + assert rows == {} + + def test_non_string_address_does_not_enable_the_sim(self, db): + rows = self._start(db, {f"{OLD_SIM}.GSPro.kGSProConnectAddress": None}) + + assert "simulators.gspro.enabled" not in rows + assert "simulators.gspro.auto_connect" not in rows + def test_never_overwrites_a_new_key_already_set(self, db): rows = self._start(db, { f"{OLD_SIM}.GSPro.kGSProConnectAddress": "10.0.0.5", diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index afd4398b..5f971c5a 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -93,6 +93,19 @@ async def test_both_sims_are_built(): assert [s["name"] for s in mgr.status()] == ["ogs", "gspro"] +@pytest.mark.asyncio +async def test_start_survives_unconvertible_settings_and_reports_an_error(): + cfg = _ogs_config(True) + cfg._v.update(_gspro_values(True)) + cfg._v["simulators.gspro.port"] = "abc" + mgr = SimManager(cfg, broadcast=None) + await mgr.start() + by_name = {s["name"]: s for s in mgr.status()} + assert by_name["ogs"]["status"] != "error" + assert by_name["gspro"]["status"] == "error" + assert "abc" in by_name["gspro"]["detail"] + + @pytest.mark.asyncio async def test_on_shot_fans_out_and_isolates_failures(): mgr = SimManager(_StubConfig({}), broadcast=None) From 18efe62a42f48287f70360be1da07c3ff352cf97 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 3 Sep 2026 18:31:26 -0400 Subject: [PATCH 116/156] send gspro the real ball state on reconnect, stop sims and startup waiting on slow work, keep club changes off the dashboard --- Software/web-server/server.py | 31 +++++++++++-------- Software/web-server/sim_manager.py | 10 ++++-- Software/web-server/sims/gspro_sim.py | 11 +++++-- Software/web-server/sims/tcp_sim.py | 3 ++ Software/web-server/tests/test_gspro_sim.py | 18 +++++++++++ Software/web-server/tests/test_ogs_sim.py | 24 ++++++++++++++ Software/web-server/tests/test_sim_manager.py | 26 ++++++++++++++++ Software/web-server/tests/test_sims_api.py | 30 ++++++++++++++++++ 8 files changed, 136 insertions(+), 17 deletions(-) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index d7aa44d0..37a2fca2 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -324,8 +324,11 @@ async def receive_shot_result(request: Request) -> Dict[str, Any]: speed_mps = float(body.get("speed_mps", 0)) message = str(body.get("message", "")) + # Club changes the C++ already applied; not for the dashboard, history or sims + if result_type_str == "Control Message": + return self._shot_result_reply() + is_status = result_type_str in self.parser._get_status_message_strings() - sim_shot = None if is_status: current = self.shot_store.get() @@ -359,6 +362,7 @@ async def receive_shot_result(request: Request) -> Dict[str, Any]: shot_data.images = list(body.get("images", [])) # The sims round for their own protocol, so they get the values before display rounding sim_shot = replace(shot_data, speed=speed_mph, launch_angle=launch_angle, side_angle=side_angle) + self._run_in_background(self.sim_manager.on_shot(sim_shot)) if result_type_str == "Hit": shot_id = shot_data.shot_id or int(datetime.now().timestamp() * 1000) images = [(Path(p).stem, p) for p in shot_data.images] @@ -366,19 +370,9 @@ async def receive_shot_result(request: Request) -> Dict[str, Any]: self.shot_store.update(shot_data) await self.connection_manager.broadcast(shot_data.to_dict()) - sim_work = None if is_status: - sim_work = self.sim_manager.on_status(result_type_str) - elif sim_shot is not None: - sim_work = self.sim_manager.on_shot(sim_shot) - if sim_work is not None: - task = asyncio.create_task(sim_work) - self.background_tasks.add(task) - task.add_done_callback(self.background_tasks.discard) - reply: Dict[str, Any] = {"status": "ok", "armed": self.sim_manager.armed} - if self.sim_manager.club: - reply["club"] = self.sim_manager.club - return reply + self._run_in_background(self.sim_manager.on_status(result_type_str)) + return self._shot_result_reply() @self.app.post("/api/internal/image-ready") async def receive_image_ready(request: Request) -> Dict[str, str]: @@ -1592,6 +1586,17 @@ def _persist_shot(self, shot_id, shot_data, images): session_id = self.session_repo.ensure_open(shot_data.timestamp, self.session_timeout_minutes) self.shot_repo.add(shot_id, session_id, shot_data, images) + def _run_in_background(self, coro) -> None: + task = asyncio.create_task(coro) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + + def _shot_result_reply(self) -> Dict[str, Any]: + reply: Dict[str, Any] = {"status": "ok", "armed": self.sim_manager.armed} + if self.sim_manager.club: + reply["club"] = self.sim_manager.club + return reply + async def shutdown_event(self) -> None: logger.info("Shutting down PiTrac Web Server...") diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index 79ec2a3d..b5104f7c 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -3,7 +3,7 @@ import asyncio import functools import logging -from typing import Awaitable, Callable, Dict, List, Optional +from typing import Awaitable, Callable, Dict, List, Optional, Set from models import ShotData from sim_interface import SimInterface @@ -33,6 +33,7 @@ def __init__(self, config_manager, broadcast: Optional[BroadcastFn] = None) -> N self._build_errors: Dict[str, Dict[str, str]] = {} self.loop: Optional[asyncio.AbstractEventLoop] = None self._reload_task: Optional[asyncio.Task] = None + self._connect_tasks: Set[asyncio.Task] = set() self._ball_detected: Optional[bool] = None self._club: Optional[str] = None self._club_sim: Optional[str] = None @@ -50,6 +51,7 @@ def build_sims(self) -> None: host=get("simulators.gspro.host") or "", port=int(get("simulators.gspro.port") or 921), on_club=functools.partial(self.set_club, "gspro"), + ball_state=lambda: bool(self._ball_detected), ), E6Sim: lambda: E6Sim( host=get("simulators.e6.host") or "", @@ -81,7 +83,9 @@ async def start(self) -> None: self.build_sims() for sim in self._sims.values(): if self.config_manager.get_config(f"simulators.{sim.name}.auto_connect"): - await sim.connect() + task = asyncio.create_task(sim.connect()) + self._connect_tasks.add(task) + task.add_done_callback(self._connect_tasks.discard) await self._broadcast_status() async def stop(self) -> None: @@ -107,6 +111,8 @@ async def _reload_after_debounce(self) -> None: await self.start() async def _disconnect_all(self) -> None: + for task in self._connect_tasks: + task.cancel() for sim in self._sims.values(): try: await sim.disconnect() diff --git a/Software/web-server/sims/gspro_sim.py b/Software/web-server/sims/gspro_sim.py index cc04580c..78666653 100644 --- a/Software/web-server/sims/gspro_sim.py +++ b/Software/web-server/sims/gspro_sim.py @@ -83,16 +83,23 @@ class GSProSim(TcpSim): display_name = "GSPro" READS = True - def __init__(self, host: str, port: int = 921, on_club: Optional[Callable[[str], None]] = None) -> None: + def __init__( + self, + host: str, + port: int = 921, + on_club: Optional[Callable[[str], None]] = None, + ball_state: Callable[[], bool] = lambda: False, + ) -> None: super().__init__(host, port) self._on_club = on_club + self._ball_state = ball_state def _encode(self, obj: Dict[str, object]) -> bytes: # The layout boost's write_json gave the C++ sender return (json.dumps(obj, indent=4) + "\n").encode("utf-8") async def _on_connected(self) -> None: - await self._send_obj(build_heartbeat(False)) + await self._send_obj(build_heartbeat(self._ball_state())) async def on_ball_state(self, ball_detected: bool) -> None: if self._writer is None: diff --git a/Software/web-server/sims/tcp_sim.py b/Software/web-server/sims/tcp_sim.py index 7c2fce12..edc0ed02 100644 --- a/Software/web-server/sims/tcp_sim.py +++ b/Software/web-server/sims/tcp_sim.py @@ -73,6 +73,9 @@ async def _open(self) -> None: self._reader, self._writer = await asyncio.wait_for( asyncio.open_connection(self.host, self.port), self.CONNECT_TIMEOUT_SEC ) + if not self._want_connected: + self._teardown_socket() + return await self._on_connected() except asyncio.CancelledError: self._teardown_socket() diff --git a/Software/web-server/tests/test_gspro_sim.py b/Software/web-server/tests/test_gspro_sim.py index 02d55f98..47fc0edd 100644 --- a/Software/web-server/tests/test_gspro_sim.py +++ b/Software/web-server/tests/test_gspro_sim.py @@ -165,6 +165,24 @@ async def test_on_ball_state_sends_a_heartbeat(): assert fake.messages[-1]["ShotDataOptions"]["LaunchMonitorBallDetected"] is True +@pytest.mark.asyncio +async def test_reconnect_heartbeat_carries_the_current_ball_state(): + fake = _FakeGSPro() + await fake.start() + ball_detected = [False] + sim = GSProSim(host="127.0.0.1", port=fake.port, ball_state=lambda: ball_detected[0]) + await sim.connect() + await asyncio.sleep(0.05) + await sim.disconnect() + ball_detected[0] = True + await sim.connect() + await asyncio.sleep(0.05) + await sim.disconnect() + await fake.stop() + + assert [m["ShotDataOptions"]["LaunchMonitorBallDetected"] for m in fake.messages] == [False, True] + + @pytest.mark.asyncio async def test_failed_heartbeat_does_not_raise(): async def broken_drain(): diff --git a/Software/web-server/tests/test_ogs_sim.py b/Software/web-server/tests/test_ogs_sim.py index 17b578d2..a48c1201 100644 --- a/Software/web-server/tests/test_ogs_sim.py +++ b/Software/web-server/tests/test_ogs_sim.py @@ -151,3 +151,27 @@ async def opens(host, port): writer.close.assert_called_once() assert sim._writer is None assert sim.status == "off" + + +@pytest.mark.asyncio +async def test_disconnect_during_connect_closes_the_late_socket(monkeypatch): + opened = asyncio.Event() + writer = MagicMock() + + async def slow_open(host, port): + await opened.wait() + return MagicMock(), writer + + monkeypatch.setattr(asyncio, "open_connection", slow_open) + sim = OGSSim(host="127.0.0.1", port=3111, keepalive_sec=999) + task = asyncio.create_task(sim.connect()) + for _ in range(5): + await asyncio.sleep(0) + await sim.disconnect() + opened.set() + await task + + writer.close.assert_called_once() + writer.write.assert_not_called() + assert sim._writer is None + assert sim.status == "off" diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index 5f971c5a..8553c90f 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -139,6 +139,32 @@ async def test_ball_state_changes_reach_sims_once(): assert sim.ball_states == [False, True] +@pytest.mark.asyncio +async def test_gspro_reads_the_ball_state_the_manager_last_saw(): + mgr = SimManager(_StubConfig(_gspro_values(True)), broadcast=None) + mgr.build_sims() + assert mgr._sims["gspro"]._ball_state() is False + await mgr.on_status("Ball Placed") + assert mgr._sims["gspro"]._ball_state() is True + + +@pytest.mark.asyncio +async def test_start_does_not_wait_for_auto_connect(): + class _Hangs(_StubSim): + async def connect(self): + await asyncio.Event().wait() + + mgr = SimManager(_StubConfig({"simulators.stub.auto_connect": True}), broadcast=None) + sim = _Hangs() + mgr.build_sims = lambda: setattr(mgr, "_sims", {"stub": sim}) + + await asyncio.wait_for(mgr.start(), 0.5) + (task,) = mgr._connect_tasks + await mgr.stop() + await asyncio.sleep(0) + assert task.cancelled() + + @pytest.mark.asyncio async def test_on_status_isolates_failures(): mgr = SimManager(_StubConfig({}), broadcast=None) diff --git a/Software/web-server/tests/test_sims_api.py b/Software/web-server/tests/test_sims_api.py index e8ab5fd3..88fbbe50 100644 --- a/Software/web-server/tests/test_sims_api.py +++ b/Software/web-server/tests/test_sims_api.py @@ -98,3 +98,33 @@ def test_status_reply_carries_armed_and_club(client, server_instance): server_instance.sim_manager.set_club("gspro", "putter") r = client.post("/api/internal/shot-result", json=status) assert r.json() == {"status": "ok", "armed": True, "club": "putter"} + + +def test_sims_are_not_held_up_by_the_shot_insert(client, server_instance): + calls = _install_on_shot_spy(server_instance) + sims_called_during_insert = [] + + def slow_persist(*args): + sims_called_during_insert.append(_wait_for(lambda: len(calls) == 1)) + + server_instance._persist_shot = slow_persist + client.post("/api/internal/shot-result", json={"result_type": 7, "speed_mps": 45.0}) + assert sims_called_during_insert == [True] + + +def test_club_change_is_not_shown_on_the_dashboard(client, server_instance): + broadcasts = [] + + async def spy(message): + broadcasts.append(message) + + server_instance.connection_manager.broadcast = spy + before = server_instance.shot_store.get() + server_instance.sim_manager.set_club("gspro", "putter") + r = client.post("/api/internal/shot-result", json={ + "result_type": 10, + "message": "Club type was set to Putter", + }) + assert r.json() == {"status": "ok", "armed": True, "club": "putter"} + assert broadcasts == [] + assert server_instance.shot_store.get() == before From d0c7fed7c315771f73c536b86ac56b54846e6289 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 3 Sep 2026 19:39:08 -0400 Subject: [PATCH 117/156] set the club straight from the web server reply and drop the queued control message path --- .../ImageProcessing/ImageProcessing.vcxproj | 1 - .../ImageProcessing.vcxproj.filters | 3 -- .../LMSourceCode/ImageProcessing/gs_clubs.cpp | 2 - .../LMSourceCode/ImageProcessing/gs_clubs.h | 5 -- .../ImageProcessing/gs_control_msg.cpp | 49 ------------------- .../ImageProcessing/gs_control_msg.h | 27 ++-------- .../ImageProcessing/gs_events.cpp | 8 --- .../LMSourceCode/ImageProcessing/gs_events.h | 4 +- .../LMSourceCode/ImageProcessing/gs_fsm.cpp | 35 ------------- .../LMSourceCode/ImageProcessing/gs_fsm.h | 4 -- .../ImageProcessing/gs_ui_system.cpp | 12 ++--- .../LMSourceCode/ImageProcessing/meson.build | 1 - 12 files changed, 7 insertions(+), 144 deletions(-) delete mode 100644 Software/LMSourceCode/ImageProcessing/gs_control_msg.cpp diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj index 3349142f..b872b283 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj @@ -255,7 +255,6 @@ - diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters index 1b19c52b..b58e6a0f 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters @@ -102,9 +102,6 @@ Source Files - - Source Files - Resource Files diff --git a/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp b/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp index 7217a194..c65da784 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp @@ -13,7 +13,6 @@ namespace golf_sim { GolfSimClubs::GsClubType GolfSimClubs::current_club_ = GolfSimClubs::kNotSelected; - std::atomic GolfSimClubs::latest_club_{ GolfSimClubs::kNotSelected }; GolfSimClubs::GsClubType GolfSimClubs::GetCurrentClubType() { @@ -22,7 +21,6 @@ namespace golf_sim { void GolfSimClubs::SetCurrentClubType(GsClubType club_type) { current_club_ = club_type; - latest_club_ = club_type; std::string club_name = (club_type == GolfSimClubs::GsClubType::kPutter) ? "Putter" : "Driver"; GS_LOG_MSG(info, "Club type set to " + club_name); diff --git a/Software/LMSourceCode/ImageProcessing/gs_clubs.h b/Software/LMSourceCode/ImageProcessing/gs_clubs.h index 14ac38c6..1ac6c6ff 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_clubs.h +++ b/Software/LMSourceCode/ImageProcessing/gs_clubs.h @@ -5,8 +5,6 @@ #pragma once -#include - // TThis is basically just a state flag for now. It's used // to determine whether the system is putting or not. @@ -25,9 +23,6 @@ namespace golf_sim { static GsClubType current_club_; - // The club most recently applied or queued to be applied, from any source. - static std::atomic latest_club_; - static GsClubType GetCurrentClubType(); static void SetCurrentClubType(GsClubType club_type); diff --git a/Software/LMSourceCode/ImageProcessing/gs_control_msg.cpp b/Software/LMSourceCode/ImageProcessing/gs_control_msg.cpp deleted file mode 100644 index 00ae8195..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_control_msg.cpp +++ /dev/null @@ -1,49 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - - -#ifdef __unix__ // Ignore in Windows environment - -#include "logging_tools.h" - -#include "gs_control_msg.h" -#include "cv_utils.h" - -namespace golf_sim { - - GsIPCControlMsg::GsIPCControlMsg() { - } - - GsIPCControlMsg::~GsIPCControlMsg() { - } - - std::string GsIPCControlMsg::FormatControlMessageType(const GsIPCControlMsgType t) { - - std::string s; - - std::map result_table = - { { GsIPCControlMsgType::kUnknown, "Unknown" }, - { GsIPCControlMsgType::kClubChangeToPutter, "Change club to putter" }, - { GsIPCControlMsgType::kClubChangeToDriver, "Change club to driver" } - }; - - if (result_table.count(t) == 0) { - return "SYSTEM ERROR: Invalid GsIPCControlMsgType: " + std::to_string((int)t); - } - return result_table[t]; - } - - std::string GsIPCControlMsg::Format() const { - - std::string control_type = FormatControlMessageType(control_type_); - - std::string s = "GsIPCControlMsg: ControlType: " + control_type + "."; - - return s; - } - -} - -#endif // #ifdef __unix__ // Ignore in Windows environment diff --git a/Software/LMSourceCode/ImageProcessing/gs_control_msg.h b/Software/LMSourceCode/ImageProcessing/gs_control_msg.h index caa808fc..449452ba 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_control_msg.h +++ b/Software/LMSourceCode/ImageProcessing/gs_control_msg.h @@ -3,29 +3,17 @@ * Copyright (C) 2022-2025, Verdant Consultants, LLC. */ -// Base class for control messages, such as those sent between the LM and -// external systems like the web dashboard and third-party golf simulators +// Control message types used by the closed-source E6 object (gs_e6_response). +// e6_auth_shim.cpp defines FormatControlMessageType for it. #pragma once #ifdef __unix__ // Ignore in Windows environment - -#include -#include -#include -#include -#include -#include - -#include "logging_tools.h" - - -// The primary object for control-type communications from the Golf Sim user interface +#include namespace golf_sim { - // TBD - Add a change-player type? enum class GsIPCControlMsgType { kUnknown = 0, kClubChangeToPutter = 1, @@ -36,17 +24,8 @@ namespace golf_sim { public: - GsIPCControlMsg(); - virtual ~GsIPCControlMsg(); - - // Returns a string representation of this result - std::string Format() const; - static std::string FormatControlMessageType(const GsIPCControlMsgType t); - public: - GsIPCControlMsgType control_type_ = GsIPCControlMsgType::kUnknown; - }; } diff --git a/Software/LMSourceCode/ImageProcessing/gs_events.cpp b/Software/LMSourceCode/ImageProcessing/gs_events.cpp index c086e6b5..000d1186 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_events.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_events.cpp @@ -40,10 +40,6 @@ namespace golf_sim { return (dynamic_cast(event) != nullptr); } - bool GolfSimEventQueue::EventIsControlEvent(GolfSimEventBase* event) { - return (dynamic_cast(event) != nullptr); - } - // Down-cast a specific derived event type into the PossibleEvent variant type // TBD - Seems really clunky - how to improve? PossibleEvent GolfSimEventQueue::ConvertEventToPossibleEvent(GolfSimEventBase *event) { @@ -55,7 +51,6 @@ namespace golf_sim { GolfSimEvent::EventLoopTick* eventLoopTick = nullptr; GolfSimEvent::BallStabilized* ballStabilized = nullptr; GolfSimEvent::BallHit* ballHit = nullptr; - GolfSimEvent::ControlMessage* controlMessage = nullptr; GolfSimEvent::FoundMultipleBalls* foundMultipleBalls = nullptr; GolfSimEvent::Camera2ImageReceived* cam2ImageReceived = nullptr; GolfSimEvent::Restart* restart = nullptr; @@ -83,9 +78,6 @@ namespace golf_sim { else if ((ballHit = dynamic_cast(event))) { possible_event = *ballHit; } - else if ((controlMessage = dynamic_cast(event))) { - possible_event = *controlMessage; - } else if ((beginWatchingForBallHit = dynamic_cast(event))) { possible_event = *beginWatchingForBallHit; } diff --git a/Software/LMSourceCode/ImageProcessing/gs_events.h b/Software/LMSourceCode/ImageProcessing/gs_events.h index 95370e8f..299e73c4 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_events.h +++ b/Software/LMSourceCode/ImageProcessing/gs_events.h @@ -95,6 +95,7 @@ namespace golf_sim { GolfBall ball_; cv::Mat ball_hit_image_; }; + // Only the closed-source E6 object builds these; pitrac_lm never queues one. class ControlMessage : public GolfSimEventBase { public: @@ -189,7 +190,6 @@ namespace golf_sim { GolfSimEvent::CheckForBallStable, GolfSimEvent::BallStabilized, GolfSimEvent::BallHit, - GolfSimEvent::ControlMessage, GolfSimEvent::BeginWatchingForBallHit, GolfSimEvent::FoundMultipleBalls, GolfSimEvent::CheckForCam2ImageReceived, @@ -232,8 +232,6 @@ namespace golf_sim { static bool EventIsShutdownEvent(GolfSimEventBase* event); - static bool EventIsControlEvent(GolfSimEventBase* event); - // Not thread safe static int GetQueueLength(); diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index 5ab56f13..c2b7c300 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -618,26 +618,6 @@ namespace golf_sim { } - bool ProcessControlMessageEvent(GolfSimEvent::ControlMessage &control_message) { - - GsIPCControlMsgType message_type = control_message.message_type_; - - GS_LOG_TRACE_MSG(trace, "Processing ControlMessage of type: " + GsIPCControlMsg::FormatControlMessageType(message_type)); - - if (message_type == GsIPCControlMsgType::kClubChangeToPutter) { - GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kPutter); - } - else if (message_type == GsIPCControlMsgType::kClubChangeToDriver) { - GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kDriver); - } - else { - GS_LOG_MSG(error, "Received ControlMessage event with unknown message type."); - } - - return true; - } - - bool RunGolfSimFsm( const GolfSimState& starting_state ) { GS_LOG_TRACE_MSG(trace, "RunGolfSimFsm"); @@ -701,21 +681,6 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "----------- Shutting Down - Received Exit Event -------------"); GolfSimGlobals::golf_sim_running_ = false; } - else if (GolfSimEventQueue::EventIsControlEvent(eventElement.e_)) { - GS_LOG_TRACE_MSG(trace, "----------- Received Control Event -------------"); - - GolfSimEvent::ControlMessage* control_message = dynamic_cast(eventElement.e_); - - if (control_message == nullptr) { - GS_LOG_MSG(error, "Could not get ControlMessage event."); - continue; - } - - if (!ProcessControlMessageEvent(*control_message)) { - GS_LOG_MSG(error, "Could not ProcessControlMessageEvent."); - continue; - } - } else { // Let the FSM handle the event golfSim.processEvent(e); diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.h b/Software/LMSourceCode/ImageProcessing/gs_fsm.h index 92eff934..2ab84fc0 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.h +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.h @@ -86,10 +86,6 @@ namespace golf_sim { // This function is really the brains of the operation. bool RunGolfSimFsm(const GolfSimState& starting_state); - // Control messages are external messages coming to the system. - // Currently driver/putter changes are the only such messages. - bool ProcessControlMessageEvent(GolfSimEvent::ControlMessage &event); - bool PerformSystemStartupTasks(); bool PerformSystemShutdownTasks(); diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index 22517ee6..5ec2bb4b 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -22,8 +22,6 @@ #include "gs_ui_system.h" #include "gs_camera.h" #include "gs_http_client.h" -#include "gs_events.h" -#include "gs_control_msg.h" #include "cv_utils.h" namespace golf_sim { @@ -134,23 +132,19 @@ namespace golf_sim { sim_armed_ = armed; GolfSimClubs::GsClubType club_type; - GsIPCControlMsgType club_instruction; if (club == "putter") { club_type = GolfSimClubs::GsClubType::kPutter; - club_instruction = GsIPCControlMsgType::kClubChangeToPutter; } else if (club == "driver") { club_type = GolfSimClubs::GsClubType::kDriver; - club_instruction = GsIPCControlMsgType::kClubChangeToDriver; } else { return; } - // Queue a change once; modes without the FSM loop never drain the bounded event queue. - if (club_type != GolfSimClubs::GetCurrentClubType() && GolfSimClubs::latest_club_.exchange(club_type) != club_type) { - GolfSimEventElement control_message{ new GolfSimEvent::ControlMessage{ club_instruction } }; - GolfSimEventQueue::QueueEvent(control_message); + // SetCurrentClubType posts again, and that reply names the club just set, so this recurses once. + if (club_type != GolfSimClubs::GetCurrentClubType()) { + GolfSimClubs::SetCurrentClubType(club_type); } } diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index d6eb9a9f..bcd02523 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -193,7 +193,6 @@ pitrac_lm_sources += ([ 'gs_events.cpp', 'worker_thread.cpp', 'camera_hardware.cpp', - 'gs_control_msg.cpp', 'gs_results.cpp', 'gs_ui_system.cpp', 'gs_result_types.cpp', From b33a1bc7d343949415800fa4e07f0729435cef02 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Thu, 3 Sep 2026 23:48:18 -0400 Subject: [PATCH 118/156] drop the stale build transcript and an unused import --- .../ImageProcessing/build.output.unix.txt | 95 ------------------- Software/web-server/parsers.py | 1 - 2 files changed, 96 deletions(-) delete mode 100644 Software/LMSourceCode/ImageProcessing/build.output.unix.txt diff --git a/Software/LMSourceCode/ImageProcessing/build.output.unix.txt b/Software/LMSourceCode/ImageProcessing/build.output.unix.txt deleted file mode 100644 index ec5c1121..00000000 --- a/Software/LMSourceCode/ImageProcessing/build.output.unix.txt +++ /dev/null @@ -1,95 +0,0 @@ -ninja: Entering directory `build' -[1/82] Generating core/version.cpp with a custom command -[2/82] Generating copy_closed_source_object with a custom command -Inserting Closed Source Object Files... -[3/82] Compiling C++ object pitrac_lm.p/meson-generated_.._core_version.cpp.o -[4/82] Compiling C++ object pitrac_lm.p/gs_globals.cpp.o -[5/82] Compiling C++ object pitrac_lm.p/ED.cpp.o -[6/82] Compiling C++ object pitrac_lm.p/EDPF.cpp.o -[7/82] Compiling C++ object pitrac_lm.p/EDColor.cpp.o -[8/82] Compiling C++ object pitrac_lm.p/ball_watcher_image_buffer.cpp.o -[9/82] Compiling C++ object pitrac_lm.p/ball_watcher.cpp.o -[10/82] Compiling C++ object pitrac_lm.p/libcamera_jpeg.cpp.o -[11/82] Compiling C++ object pitrac_lm.p/colorsys.cpp.o -[12/82] Compiling C++ object pitrac_lm.p/gs_test.cpp.o -[13/82] Compiling C++ object pitrac_lm.p/gs_fsm.cpp.o -[14/82] Compiling C++ object pitrac_lm.p/libcamera_interface.cpp.o -[15/82] Compiling C++ object pitrac_lm.p/pulse_strobe.cpp.o -[16/82] Compiling C++ object pitrac_lm.p/ball_image_proc.cpp.o -[17/82] Compiling C++ object pitrac_lm.p/cv_utils.cpp.o -[18/82] Compiling C++ object pitrac_lm.p/EllipseDetectorCommon.cpp.o -[19/82] Compiling C++ object pitrac_lm.p/golf_ball.cpp.o -[20/82] Compiling C++ object pitrac_lm.p/EllipseDetectorYaed.cpp.o -In file included from ../EllipseDetectorYaed.cpp:15: -../EllipseDetectorYaed.h: In constructor ‘golf_sim::CEllipseDetectorYaed::CEllipseDetectorYaed()’: -../EllipseDetectorYaed.h:105:24: warning: ‘golf_sim::CEllipseDetectorYaed::_times’ will be initialized after [-Wreorder] - 105 | vector _times; // _times is a vector containing the execution time of each step. - | ^~~~~~ -../EllipseDetectorYaed.h:104:24: warning: ‘std::vector > golf_sim::CEllipseDetectorYaed::_timesHelper’ [-Wreorder] - 104 | vector _timesHelper; - | ^~~~~~~~~~~~ -../EllipseDetectorYaed.cpp:20:1: warning: when initialized here [-Wreorder] - 20 | CEllipseDetectorYaed::CEllipseDetectorYaed(void) : _times(6, 0.0), _timesHelper(6, 0.0) - | ^~~~~~~~~~~~~~~~~~~~ -[21/82] Compiling C++ object pitrac_lm.p/gs_options.cpp.o -[22/82] Compiling C++ object pitrac_lm.p/gs_clubs.cpp.o -[23/82] Compiling C++ object pitrac_lm.p/gs_camera.cpp.o -[24/82] Compiling C++ object pitrac_lm.p/gs_gspro_response.cpp.o -[25/82] Compiling C++ object pitrac_lm.p/gs_config.cpp.o -[26/82] Compiling C++ object pitrac_lm.p/gs_sim_interface.cpp.o -[27/82] Compiling C++ object pitrac_lm.p/gs_gspro_interface.cpp.o -[28/82] Compiling C++ object pitrac_lm.p/gs_gspro_test_server.cpp.o -[29/82] Compiling C++ object pitrac_lm.p/gs_e6_results.cpp.o -[30/82] Compiling C++ object pitrac_lm.p/gs_e6_interface.cpp.o -[31/82] Compiling C++ object pitrac_lm.p/logging_tools.cpp.o -[32/82] Compiling C++ object pitrac_lm.p/gs_sim_socket_interface.cpp.o -[33/82] Compiling C++ object pitrac_lm.p/worker_thread.cpp.o -[34/82] Compiling C++ object pitrac_lm.p/gs_events.cpp.o -[35/82] Compiling C++ object pitrac_lm.p/camera_hardware.cpp.o -[36/82] Compiling C++ object pitrac_lm.p/gs_gspro_results.cpp.o -[37/82] Compiling C++ object pitrac_lm.p/gs_ipc_message.cpp.o -[38/82] Compiling C++ object pitrac_lm.p/gs_ipc_control_msg.cpp.o -[39/82] Compiling C++ object pitrac_lm.p/gs_results.cpp.o -[40/82] Compiling C++ object pitrac_lm.p/gs_ipc_mat.cpp.o -[41/82] Compiling C++ object pitrac_lm.p/gs_ui_system.cpp.o -[42/82] Compiling C++ object pitrac_lm.p/gs_ipc_result.cpp.o -[43/82] Compiling C++ object pitrac_lm.p/core_buffer_sync.cpp.o -[44/82] Compiling C++ object pitrac_lm.p/core_dma_heaps.cpp.o -[45/82] Compiling C++ object pitrac_lm.p/gs_ipc_test.cpp.o -[46/82] Compiling C++ object pitrac_lm.p/gs_ipc_system.cpp.o -[47/82] Compiling C++ object pitrac_lm.p/gs_message_consumer.cpp.o -[48/82] Compiling C++ object pitrac_lm.p/core_post_processor.cpp.o -[49/82] Compiling C++ object pitrac_lm.p/gs_message_producer.cpp.o -[50/82] Compiling C++ object pitrac_lm.p/core_options.cpp.o -[51/82] Compiling C++ object pitrac_lm.p/encoder_encoder.cpp.o -[52/82] Compiling C++ object pitrac_lm.p/encoder_h264_encoder.cpp.o -[53/82] Compiling C++ object pitrac_lm.p/encoder_mjpeg_encoder.cpp.o -[54/82] Compiling C++ object pitrac_lm.p/image_bmp.cpp.o -[55/82] Compiling C++ object pitrac_lm.p/encoder_libav_encoder.cpp.o -[56/82] Compiling C++ object pitrac_lm.p/core_rpicam_app.cpp.o -[57/82] Compiling C++ object pitrac_lm.p/encoder_null_encoder.cpp.o -[58/82] Compiling C++ object pitrac_lm.p/image_dng.cpp.o -[59/82] Compiling C++ object pitrac_lm.p/image_png.cpp.o -[60/82] Compiling C++ object pitrac_lm.p/image_jpeg.cpp.o -[61/82] Compiling C++ object pitrac_lm.p/output_circular_output.cpp.o -[62/82] Compiling C++ object pitrac_lm.p/output_file_output.cpp.o -[63/82] Compiling C++ object pitrac_lm.p/image_yuv.cpp.o -[64/82] Compiling C++ object pitrac_lm.p/output_net_output.cpp.o -[65/82] Compiling C++ object pitrac_lm.p/output_output.cpp.o -[66/82] Compiling C++ object pitrac_lm.p/preview_null_preview.cpp.o -[67/82] Compiling C++ object pitrac_lm.p/preview_preview.cpp.o -[68/82] Compiling C++ object pitrac_lm.p/post_processing_stages_histogram.cpp.o -[69/82] Compiling C++ object pitrac_lm.p/preview_drm_preview.cpp.o -[70/82] Compiling C++ object pitrac_lm.p/preview_egl_preview.cpp.o -[71/82] Compiling C++ object pitrac_lm.p/post_processing_stages_pwl.cpp.o -[72/82] Compiling C++ object pitrac_lm.p/post_processing_stages_negate_stage.cpp.o -[73/82] Compiling C++ object pitrac_lm.p/preview_qt_preview.cpp.o -[74/82] Compiling C++ object pitrac_lm.p/post_processing_stages_post_processing_stage.cpp.o -[75/82] Compiling C++ object pitrac_lm.p/post_processing_stages_hdr_stage.cpp.o -[76/82] Compiling C++ object pitrac_lm.p/post_processing_stages_sobel_cv_stage.cpp.o -[77/82] Compiling C++ object pitrac_lm.p/post_processing_stages_annotate_cv_stage.cpp.o -[78/82] Compiling C++ object pitrac_lm.p/post_processing_stages_plot_pose_cv_stage.cpp.o -[79/82] Compiling C++ object pitrac_lm.p/post_processing_stages_face_detect_cv_stage.cpp.o -[80/82] Compiling C++ object pitrac_lm.p/post_processing_stages_object_detect_draw_cv_stage.cpp.o -[81/82] Compiling C++ object pitrac_lm.p/post_processing_stages_motion_detect_stage.cpp.o -[82/82] Linking target pitrac_lm diff --git a/Software/web-server/parsers.py b/Software/web-server/parsers.py index 65c09079..afe94ab6 100644 --- a/Software/web-server/parsers.py +++ b/Software/web-server/parsers.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import Any, Dict, List -from constants import MPS_TO_MPH from models import ResultType, ShotData logger = logging.getLogger(__name__) From 65ee1da12e1a2a57c7b5302462e676c43727c8bc Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 4 Sep 2026 18:50:32 -0400 Subject: [PATCH 119/156] close the connect and reload races in the sim layer --- Software/web-server/sim_manager.py | 20 ++++++----- Software/web-server/sims/tcp_sim.py | 14 +++++--- Software/web-server/tests/test_gspro_sim.py | 18 ++++++++++ Software/web-server/tests/test_ogs_sim.py | 21 +++++++++++ Software/web-server/tests/test_sim_manager.py | 35 +++++++++++++++++++ 5 files changed, 95 insertions(+), 13 deletions(-) diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py index b5104f7c..d9abd007 100644 --- a/Software/web-server/sim_manager.py +++ b/Software/web-server/sim_manager.py @@ -34,6 +34,7 @@ def __init__(self, config_manager, broadcast: Optional[BroadcastFn] = None) -> N self.loop: Optional[asyncio.AbstractEventLoop] = None self._reload_task: Optional[asyncio.Task] = None self._connect_tasks: Set[asyncio.Task] = set() + self._reload_lock = asyncio.Lock() self._ball_detected: Optional[bool] = None self._club: Optional[str] = None self._club_sim: Optional[str] = None @@ -107,8 +108,9 @@ def _schedule_reload(self) -> None: async def _reload_after_debounce(self) -> None: await asyncio.sleep(self.RELOAD_DEBOUNCE_SEC) - await self._disconnect_all() - await self.start() + async with self._reload_lock: + await self._disconnect_all() + await self.start() async def _disconnect_all(self) -> None: for task in self._connect_tasks: @@ -156,16 +158,18 @@ def set_club(self, sim_name: str, club: str) -> None: self._club_sim = sim_name async def connect(self, name: str) -> None: - sim = self._sims.get(name) - if sim is None: - raise KeyError(name) - await sim.connect() + await (await self._current_sim(name)).connect() async def disconnect(self, name: str) -> None: - sim = self._sims.get(name) + await (await self._current_sim(name)).disconnect() + + async def _current_sim(self, name: str) -> SimInterface: + # Waits out a reload in progress, so the request reaches the rebuilt sim and not one being torn down + async with self._reload_lock: + sim = self._sims.get(name) if sim is None: raise KeyError(name) - await sim.disconnect() + return sim def status(self) -> List[Dict[str, str]]: return [sim.info() for sim in self._sims.values()] + list(self._build_errors.values()) diff --git a/Software/web-server/sims/tcp_sim.py b/Software/web-server/sims/tcp_sim.py index edc0ed02..9bd533cb 100644 --- a/Software/web-server/sims/tcp_sim.py +++ b/Software/web-server/sims/tcp_sim.py @@ -73,22 +73,26 @@ async def _open(self) -> None: self._reader, self._writer = await asyncio.wait_for( asyncio.open_connection(self.host, self.port), self.CONNECT_TIMEOUT_SEC ) - if not self._want_connected: - self._teardown_socket() - return - await self._on_connected() + if self._want_connected: + await self._on_connected() except asyncio.CancelledError: self._teardown_socket() await self._set_status(STATUS_OFF, "") raise except Exception as e: + if not self._want_connected: + return logger.warning(f"{self.display_name} connect failed: {e!r}") await self._set_status(STATUS_ERROR, str(e) or type(e).__name__) self._schedule_reconnect() return + # A disconnect during any await above already tore down and set the status + if not self._want_connected or self._writer is None: + self._teardown_socket() + return if self.CONNECTED_ON_OPEN: await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") - if self.READS: + if self.READS and self._reader is not None: self._spawn(self._read_loop()) async def _on_connected(self) -> None: diff --git a/Software/web-server/tests/test_gspro_sim.py b/Software/web-server/tests/test_gspro_sim.py index 47fc0edd..f8646c6d 100644 --- a/Software/web-server/tests/test_gspro_sim.py +++ b/Software/web-server/tests/test_gspro_sim.py @@ -287,3 +287,21 @@ async def test_peer_close_sets_error_and_schedules_reconnect(): assert sim._reconnect_task is not None and not sim._reconnect_task.done() await sim.disconnect() await fake.stop() + + +@pytest.mark.asyncio +async def test_disconnect_while_reporting_connected_starts_no_reader(): + fake = _FakeGSPro() + await fake.start() + sim = GSProSim(host="127.0.0.1", port=fake.port) + + async def disconnect_once_connected(): + if sim.status == "connected": + await sim.disconnect() + + sim.set_status_callback(disconnect_once_connected) + await sim.connect() + await fake.stop() + + assert sim._tasks == [] + assert sim.status == "off" diff --git a/Software/web-server/tests/test_ogs_sim.py b/Software/web-server/tests/test_ogs_sim.py index a48c1201..54da292c 100644 --- a/Software/web-server/tests/test_ogs_sim.py +++ b/Software/web-server/tests/test_ogs_sim.py @@ -175,3 +175,24 @@ async def slow_open(host, port): writer.write.assert_not_called() assert sim._writer is None assert sim.status == "off" + + +@pytest.mark.asyncio +async def test_failed_connect_after_disconnect_leaves_status_off(monkeypatch): + refused = asyncio.Event() + + async def slow_refusal(host, port): + await refused.wait() + raise ConnectionRefusedError("refused") + + monkeypatch.setattr(asyncio, "open_connection", slow_refusal) + sim = OGSSim(host="127.0.0.1", port=3111, keepalive_sec=999) + task = asyncio.create_task(sim.connect()) + for _ in range(5): + await asyncio.sleep(0) + await sim.disconnect() + refused.set() + await task + + assert sim.status == "off" + assert sim._reconnect_task is None diff --git a/Software/web-server/tests/test_sim_manager.py b/Software/web-server/tests/test_sim_manager.py index 8553c90f..ee566831 100644 --- a/Software/web-server/tests/test_sim_manager.py +++ b/Software/web-server/tests/test_sim_manager.py @@ -268,6 +268,41 @@ async def test_config_change_rebuilds_sims(monkeypatch): assert [s["name"] for s in mgr.status()] == ["ogs"] +@pytest.mark.asyncio +async def test_connect_during_reload_reaches_the_rebuilt_sim(monkeypatch): + monkeypatch.setattr(SimManager, "RELOAD_DEBOUNCE_SEC", 0) + release = asyncio.Event() + + class _SlowStop(_StubSim): + async def disconnect(self): + await release.wait() + self.connected = False + + mgr = SimManager(_StubConfig({}), broadcast=None) + mgr.loop = asyncio.get_running_loop() + built = [] + + def build(): + built.append(_SlowStop()) + mgr._sims = {"stub": built[-1]} + + mgr.build_sims = build + mgr.build_sims() + mgr._on_config_change("simulators.stub.host", "x") + for _ in range(5): + await asyncio.sleep(0) + connecting = asyncio.create_task(mgr.connect("stub")) + for _ in range(5): + await asyncio.sleep(0) + release.set() + await asyncio.wait_for(asyncio.gather(mgr._reload_task, connecting), 1) + + old, new = built + assert old.connected is False + assert new.connected is True + assert mgr._sims["stub"] is new + + @pytest.mark.asyncio async def test_burst_of_changes_reloads_once(monkeypatch): monkeypatch.setattr(SimManager, "RELOAD_DEBOUNCE_SEC", 0.05) From 1f4974e18b738a8b81952826afd6ed584eda76bf Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Fri, 4 Sep 2026 23:06:23 -0400 Subject: [PATCH 120/156] only record the sim club from replies and apply it at startup and right before cam2 arms --- .../LMSourceCode/ImageProcessing/gs_fsm.cpp | 13 ++++++++++++- .../ImageProcessing/gs_ui_system.cpp | 17 ++++++++--------- .../LMSourceCode/ImageProcessing/gs_ui_system.h | 8 +++++++- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index c2b7c300..521183de 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -289,6 +289,13 @@ namespace golf_sim { // Whatever happens, this is a new shot with a new shot number GsUISystem::IncrementShotCounter(); + // The last POST before arming was the stabilization status, so a club the sims picked + // since the previous shot applies here and then holds through the hit and its analysis. + GolfSimClubs::GsClubType sim_club = GsUISystem::SimClub(); + if (sim_club != GolfSimClubs::kNotSelected && sim_club != GolfSimClubs::GetCurrentClubType()) { + GolfSimClubs::SetCurrentClubType(sim_club); + } + // Arm Camera2 thread to start waiting for the external trigger g_cam2_thread.arm(); @@ -813,7 +820,11 @@ namespace golf_sim { bool kStartInPuttingMode = false; GolfSimConfiguration::SetConstant("gs_config.modes.kStartInPuttingMode", kStartInPuttingMode); - if (kStartInPuttingMode) { + // A club the sims reported in reply to the Initializing post wins over the configured default + if (GsUISystem::SimClub() != GolfSimClubs::kNotSelected) { + GolfSimClubs::SetCurrentClubType(GsUISystem::SimClub()); + } + else if (kStartInPuttingMode) { GS_LOG_MSG(info, "Starting in Putting Mode."); GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kPutter); } diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index 5ec2bb4b..325d6dde 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -38,6 +38,7 @@ namespace golf_sim { std::vector GsUISystem::current_shot_image_paths_; std::mutex GsUISystem::shot_images_mutex_; std::atomic GsUISystem::sim_armed_{ true }; + std::atomic GsUISystem::sim_club_{ GolfSimClubs::kNotSelected }; long GsUISystem::shot_counter_ = 0; std::string GsUISystem::CurrentShotRelativePath(const std::string& file_name) { @@ -103,6 +104,10 @@ namespace golf_sim { return sim_armed_; } + GolfSimClubs::GsClubType GsUISystem::SimClub() { + return sim_club_; + } + void GsUISystem::IncrementShotCounter() { shot_counter_++; } @@ -131,20 +136,14 @@ namespace golf_sim { } sim_armed_ = armed; - GolfSimClubs::GsClubType club_type; if (club == "putter") { - club_type = GolfSimClubs::GsClubType::kPutter; + sim_club_ = GolfSimClubs::GsClubType::kPutter; } else if (club == "driver") { - club_type = GolfSimClubs::GsClubType::kDriver; + sim_club_ = GolfSimClubs::GsClubType::kDriver; } else { - return; - } - - // SetCurrentClubType posts again, and that reply names the club just set, so this recurses once. - if (club_type != GolfSimClubs::GetCurrentClubType()) { - GolfSimClubs::SetCurrentClubType(club_type); + sim_club_ = GolfSimClubs::GsClubType::kNotSelected; } } diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h index 1d3067ab..0bfd801c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h @@ -17,6 +17,7 @@ #include "logging_tools.h" #include "golf_ball.h" +#include "gs_clubs.h" #include "gs_result_types.h" @@ -60,6 +61,10 @@ namespace golf_sim { // Whether the web server's sims are ready for a shot, as of its last reply. static bool SimArmed(); + // The club the web server's sims last asked for, kNotSelected when none has. + // The FSM applies it only at startup and just before Camera2 is armed. + static GolfSimClubs::GsClubType SimClub(); + // Numbers each shot for the log lines and image names. static void IncrementShotCounter(); static long GetShotCounter(); @@ -74,10 +79,11 @@ namespace golf_sim { static void ClearWebserverImages(); private: - // Posts to the web server and applies the armed state and club in its reply. + // Posts to the web server and records the armed state and club in its reply. static void PostResult(const std::string& json); static std::atomic sim_armed_; + static std::atomic sim_club_; static long shot_counter_; }; From 30a11c7594edc3faeb3e2c74cee2a7bc077f00fd Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 5 Sep 2026 10:24:06 -0400 Subject: [PATCH 121/156] cap how long a sim disconnect can wait on a stuck peer --- Software/web-server/sims/e6_sim.py | 2 +- Software/web-server/sims/tcp_sim.py | 6 ++++-- Software/web-server/tests/test_e6_sim.py | 18 ++++++++++++++++++ Software/web-server/tests/test_ogs_sim.py | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Software/web-server/sims/e6_sim.py b/Software/web-server/sims/e6_sim.py index c3615041..340a2346 100644 --- a/Software/web-server/sims/e6_sim.py +++ b/Software/web-server/sims/e6_sim.py @@ -65,7 +65,7 @@ async def _auth_deadline(self) -> None: async def disconnect(self) -> None: if self._writer is not None: try: - await self._send_obj({"Type": "Disconnect"}) + await asyncio.wait_for(self._send_obj({"Type": "Disconnect"}), self.DISCONNECT_TIMEOUT_SEC) except Exception as e: logger.warning(f"E6 disconnect message failed: {e}") await super().disconnect() diff --git a/Software/web-server/sims/tcp_sim.py b/Software/web-server/sims/tcp_sim.py index 9bd533cb..3689a23a 100644 --- a/Software/web-server/sims/tcp_sim.py +++ b/Software/web-server/sims/tcp_sim.py @@ -38,6 +38,7 @@ class TcpSim(SimInterface): """ CONNECT_TIMEOUT_SEC = 5 + DISCONNECT_TIMEOUT_SEC = 2 READS = False CONNECTED_ON_OPEN = True @@ -192,7 +193,8 @@ async def disconnect(self) -> None: self._teardown_socket() if writer is not None: try: - await writer.wait_closed() + await asyncio.wait_for(writer.wait_closed(), self.DISCONNECT_TIMEOUT_SEC) except Exception: - pass + # A peer that stopped reading leaves buffered data, so drop it rather than wait + writer.transport.abort() await self._set_status(STATUS_OFF, "") diff --git a/Software/web-server/tests/test_e6_sim.py b/Software/web-server/tests/test_e6_sim.py index b4dcfba6..edbc8ea0 100644 --- a/Software/web-server/tests/test_e6_sim.py +++ b/Software/web-server/tests/test_e6_sim.py @@ -350,3 +350,21 @@ async def test_disconnect_sends_disconnect_first(): assert sim.status == "off" assert sim.armed is False await fake.stop() + + +@pytest.mark.asyncio +async def test_disconnect_does_not_wait_forever_on_a_peer_that_stopped_reading(): + async def stalls(): + await asyncio.Event().wait() + + writer = MagicMock() + writer.drain = stalls + writer.wait_closed = stalls + sim = E6Sim(host="127.0.0.1", port=2483) + sim.DISCONNECT_TIMEOUT_SEC = 0.05 + sim._writer = writer + + await asyncio.wait_for(sim.disconnect(), 1) + + writer.transport.abort.assert_called_once() + assert sim.status == "off" diff --git a/Software/web-server/tests/test_ogs_sim.py b/Software/web-server/tests/test_ogs_sim.py index 54da292c..19bade9a 100644 --- a/Software/web-server/tests/test_ogs_sim.py +++ b/Software/web-server/tests/test_ogs_sim.py @@ -196,3 +196,20 @@ async def slow_refusal(host, port): assert sim.status == "off" assert sim._reconnect_task is None + + +@pytest.mark.asyncio +async def test_disconnect_does_not_wait_forever_for_the_socket_to_close(): + async def never_closes(): + await asyncio.Event().wait() + + writer = MagicMock() + writer.wait_closed = never_closes + sim = OGSSim(host="127.0.0.1", port=3111, keepalive_sec=999) + sim.DISCONNECT_TIMEOUT_SEC = 0.05 + sim._writer = writer + + await asyncio.wait_for(sim.disconnect(), 1) + + writer.transport.abort.assert_called_once() + assert sim.status == "off" From bc007640856b570f73fb86f81cdcb51e0ceec5c5 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 5 Sep 2026 10:30:51 -0400 Subject: [PATCH 122/156] apply the sim club only in the fsm so still and dev modes keep their default --- .../LMSourceCode/ImageProcessing/gs_fsm.cpp | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index 521183de..08b7e5cc 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -37,6 +37,14 @@ namespace golf_sim { static Camera2Thread g_cam2_thread; + // Only the FSM applies the sims' club, and only where a change cannot land mid-shot + static void ApplySimClub() { + GolfSimClubs::GsClubType sim_club = GsUISystem::SimClub(); + if (sim_club != GolfSimClubs::kNotSelected && sim_club != GolfSimClubs::GetCurrentClubType()) { + GolfSimClubs::SetCurrentClubType(sim_club); + } + } + static int signal_received; static void default_signal_handler(int signal_number) { @@ -291,10 +299,7 @@ namespace golf_sim { // The last POST before arming was the stabilization status, so a club the sims picked // since the previous shot applies here and then holds through the hit and its analysis. - GolfSimClubs::GsClubType sim_club = GsUISystem::SimClub(); - if (sim_club != GolfSimClubs::kNotSelected && sim_club != GolfSimClubs::GetCurrentClubType()) { - GolfSimClubs::SetCurrentClubType(sim_club); - } + ApplySimClub(); // Arm Camera2 thread to start waiting for the external trigger g_cam2_thread.arm(); @@ -639,6 +644,9 @@ namespace golf_sim { return false; } + // A club the sims named in reply to the startup Initializing post replaces the configured default + ApplySimClub(); + GolfSimConfiguration::SetConstant("gs_config.ipc_interface.kMaxCam2ImageReceivedTimeMs", kMaxCam2ImageReceivedTimeMs); GolfSimConfiguration::SetConstant("gs_config.user_interface.kWebServerCamera2Image", kWebServerCamera2Image); @@ -820,11 +828,7 @@ namespace golf_sim { bool kStartInPuttingMode = false; GolfSimConfiguration::SetConstant("gs_config.modes.kStartInPuttingMode", kStartInPuttingMode); - // A club the sims reported in reply to the Initializing post wins over the configured default - if (GsUISystem::SimClub() != GolfSimClubs::kNotSelected) { - GolfSimClubs::SetCurrentClubType(GsUISystem::SimClub()); - } - else if (kStartInPuttingMode) { + if (kStartInPuttingMode) { GS_LOG_MSG(info, "Starting in Putting Mode."); GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kPutter); } From 626bca58fcad02ef0c468ec00cee2e7614b59cac Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Mon, 7 Sep 2026 21:26:17 -0400 Subject: [PATCH 123/156] pin opencv for the tests so ci stops picking up opencv 5 --- Software/web-server/requirements-test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Software/web-server/requirements-test.txt b/Software/web-server/requirements-test.txt index 73fc854d..ea3be9d8 100644 --- a/Software/web-server/requirements-test.txt +++ b/Software/web-server/requirements-test.txt @@ -6,4 +6,4 @@ pytest-xdist==3.5.0 httpx==0.25.2 websocket-client==1.6.4 faker==20.1.0 -opencv-python-headless>=4.7.0 \ No newline at end of file +opencv-python-headless==4.14.0.94 From aeac3a480f7fd18595ea900adc82ad2fe81c5c4a Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 9 Sep 2026 20:07:33 -0400 Subject: [PATCH 124/156] take the status strip colors from pico --- Software/web-server/eslint.config.js | 2 +- Software/web-server/static/css/dashboard.css | 23 +++++++++++++------- Software/web-server/static/js/common.js | 5 +++-- Software/web-server/static/js/dashboard.js | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Software/web-server/eslint.config.js b/Software/web-server/eslint.config.js index 2bb57595..89736af3 100644 --- a/Software/web-server/eslint.config.js +++ b/Software/web-server/eslint.config.js @@ -32,7 +32,7 @@ module.exports = [ rules: { 'no-unused-vars': ['error', { 'argsIgnorePattern': '^_|^e$', - 'varsIgnorePattern': '^(saveChanges|resetAll|reloadConfig|showDiff|exportConfig|importConfig|filterConfig|closeModal|setTheme|openImage|resetShot|controlPiTrac|startBtn|stopBtn|restartBtn|calibration)$' + 'varsIgnorePattern': '^(saveChanges|resetAll|reloadConfig|showDiff|exportConfig|importConfig|filterConfig|closeModal|setTheme|openImage|resetShot|controlPiTrac|startBtn|stopBtn|restartBtn|calibration|showStatusMessage)$' }], 'no-console': ['warn', { 'allow': ['warn', 'error'] }], 'curly': ['error', 'multi-line'], diff --git a/Software/web-server/static/css/dashboard.css b/Software/web-server/static/css/dashboard.css index cfc623b3..f1d7562b 100644 --- a/Software/web-server/static/css/dashboard.css +++ b/Software/web-server/static/css/dashboard.css @@ -48,28 +48,35 @@ animation: pulse 2s infinite; } -.status-strip.waiting { border-left-color: var(--color-warning); } +/* Traffic-light ramp at matched OKLCH L 80 / C 0.17, hue 50/85/150. */ +.status-strip.waiting { border-left-color: oklch(80% 0.17 50); } .status-strip.waiting .ball-icon { - background-color: var(--color-warning); + background-color: oklch(80% 0.17 50); animation: pulse 2s infinite; } +.status-strip.waiting #status-strip-title { + color: oklch(80% 0.17 50); +} -.status-strip.stabilizing { border-left-color: var(--color-warning); } +.status-strip.stabilizing { border-left-color: oklch(80% 0.17 85); } .status-strip.stabilizing .ball-icon { - background-color: var(--color-warning); + background-color: oklch(80% 0.17 85); animation: bounce 1s infinite; } +.status-strip.stabilizing #status-strip-title { + color: oklch(80% 0.17 85); +} .status-strip.ready { - border-left-color: var(--color-success); - background: linear-gradient(90deg, color-mix(in oklch, var(--color-success) 6%, transparent) 0%, transparent 40%); + border-left-color: oklch(80% 0.17 150); + background: linear-gradient(90deg, color-mix(in oklch, oklch(80% 0.17 150) 6%, transparent) 0%, transparent 40%); } .status-strip.ready .ball-icon { - background-color: var(--color-success); + background-color: oklch(80% 0.17 150); transform: scale(1.05); } .status-strip.ready #status-strip-title { - color: var(--color-success); + color: oklch(80% 0.17 150); } .status-strip.hit { border-left-color: var(--color-primary); } diff --git a/Software/web-server/static/js/common.js b/Software/web-server/static/js/common.js index 2b144c3e..c2f73938 100644 --- a/Software/web-server/static/js/common.js +++ b/Software/web-server/static/js/common.js @@ -145,7 +145,7 @@ async function requireStrobeSafe() { if (data.safe) return true; showStrobeSafetyModal(data.reason || ''); return false; - } catch (_) { + } catch { showStrobeSafetyModal('Could not verify strobe safety — check server connection.'); return false; } @@ -167,5 +167,6 @@ document.addEventListener('DOMContentLoaded', () => { checkSystemStatus(); checkPiTracStatus(); setInterval(checkSystemStatus, 5000); - setInterval(checkPiTracStatus, 5000); + // Indirect call so page-specific wrappers (e.g. dashboard.js) take effect. + setInterval(() => window.checkPiTracStatus(), 5000); }); diff --git a/Software/web-server/static/js/dashboard.js b/Software/web-server/static/js/dashboard.js index 2efc92bb..1acc339a 100644 --- a/Software/web-server/static/js/dashboard.js +++ b/Software/web-server/static/js/dashboard.js @@ -126,7 +126,7 @@ function updateBallStatus(resultType, message, isPiTracRunning) { strip.classList.add('stabilizing'); title.textContent = 'Ball Detected'; setStripMessage(message || 'Waiting for ball to stabilize...'); - } else if (normalizedType.includes('ball ready') || normalizedType.includes('ready')) { + } else if (normalizedType.includes('ball placed') || normalizedType.includes('ball ready') || normalizedType.includes('ready')) { strip.classList.add('ready'); title.textContent = 'Ready to Hit!'; setStripMessage(message || 'Ball is ready, take your shot'); From cc8926b53951bb58c92656847f85ae4b9a1d2cac Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Wed, 9 Sep 2026 22:25:13 -0400 Subject: [PATCH 125/156] vendor daisyui and tailwind, version static assets, make eslint pass and run it in ci --- .github/workflows/web-server-tests.yml | 13 + .gitignore | 3 +- Software/web-server/eslint.config.js | 20 +- Software/web-server/server.py | 2 + Software/web-server/static/js/calibration.js | 2 +- Software/web-server/static/js/common.js | 2 + Software/web-server/static/js/config.js | 16 +- Software/web-server/static/js/dashboard.js | 1 + Software/web-server/static/js/testing.js | 2 +- Software/web-server/static/js/update.js | 3 +- .../static/vendor/daisyui-5.7.47.css | 1 + .../vendor/tailwindcss-browser-4.3.3.js | 952 ++++++++++++++++++ Software/web-server/templates/base.html | 12 +- .../web-server/templates/calibration.html | 6 +- Software/web-server/templates/config.html | 4 +- Software/web-server/templates/dashboard.html | 4 +- Software/web-server/templates/history.html | 2 +- Software/web-server/templates/logs.html | 4 +- Software/web-server/templates/testing.html | 4 +- Software/web-server/templates/update.html | 4 +- Software/web-server/tests/test_smoke.py | 8 + 21 files changed, 1025 insertions(+), 40 deletions(-) create mode 100644 Software/web-server/static/vendor/daisyui-5.7.47.css create mode 100644 Software/web-server/static/vendor/tailwindcss-browser-4.3.3.js diff --git a/.github/workflows/web-server-tests.yml b/.github/workflows/web-server-tests.yml index 2ff37d11..c8e1ab55 100644 --- a/.github/workflows/web-server-tests.yml +++ b/.github/workflows/web-server-tests.yml @@ -40,6 +40,19 @@ jobs: pip install -r requirements.txt pip install -r requirements-test.txt + - name: Set up Node + if: matrix.python-version == '3.12' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Lint JavaScript + if: matrix.python-version == '3.12' + working-directory: Software/web-server + run: | + npm ci + npx eslint 'static/**/*.js' + - name: Run tests with coverage working-directory: Software/web-server run: | diff --git a/.gitignore b/.gitignore index 6cf86bb1..dc9d5717 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,5 @@ packaging/pitrac *.FCBak # Node.js (root-level tooling) -node_modules/ \ No newline at end of file +node_modules/ +!Software/web-server/static/vendor/ diff --git a/Software/web-server/eslint.config.js b/Software/web-server/eslint.config.js index 89736af3..528f0c25 100644 --- a/Software/web-server/eslint.config.js +++ b/Software/web-server/eslint.config.js @@ -1,6 +1,9 @@ +const js = require('@eslint/js'); const prettierConfig = require('eslint-config-prettier'); module.exports = [ + { ignores: ['static/vendor/**', 'static/js/lucide.min.js'] }, + js.configs.recommended, { files: ['**/*.js'], languageOptions: { @@ -26,12 +29,25 @@ module.exports = [ Array: 'readonly', Number: 'readonly', String: 'readonly', - isNaN: 'readonly' + isNaN: 'readonly', + localStorage: 'readonly', + navigator: 'readonly', + requestAnimationFrame: 'readonly', + FormData: 'readonly', + lucide: 'readonly', + location: 'readonly', + HTMLElement: 'readonly', + Event: 'readonly', + CustomEvent: 'readonly', + getComputedStyle: 'readonly', + Node: 'readonly', + FileReader: 'readonly' } }, rules: { 'no-unused-vars': ['error', { - 'argsIgnorePattern': '^_|^e$', + 'argsIgnorePattern': '^_', + 'caughtErrors': 'none', 'varsIgnorePattern': '^(saveChanges|resetAll|reloadConfig|showDiff|exportConfig|importConfig|filterConfig|closeModal|setTheme|openImage|resetShot|controlPiTrac|startBtn|stopBtn|restartBtn|calibration|showStatusMessage)$' }], 'no-console': ['warn', { 'allow': ['warn', 'error'] }], diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 37a2fca2..abc41f8c 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -4,6 +4,7 @@ import os import shutil import subprocess +import time from contextlib import asynccontextmanager from dataclasses import replace from datetime import datetime @@ -192,6 +193,7 @@ class PiTracServer: def __init__(self): self.app = FastAPI(title="PiTrac Dashboard", lifespan=self._lifespan) self.templates = Jinja2Templates(directory=str(self._BASE_DIR / "templates")) + self.templates.env.globals["asset_version"] = str(int(time.time())) self.connection_manager = ConnectionManager() self.shot_store = ShotDataStore() self.parser = ShotDataParser() diff --git a/Software/web-server/static/js/calibration.js b/Software/web-server/static/js/calibration.js index 2b05d42d..7a69d345 100644 --- a/Software/web-server/static/js/calibration.js +++ b/Software/web-server/static/js/calibration.js @@ -1,3 +1,4 @@ +/* global requireStrobeSafe */ /** * PiTrac Calibration UI Controller */ @@ -180,7 +181,6 @@ class CalibrationManager { return; } this.showStep(3); - } else if (this.currentStep === 3) { } } diff --git a/Software/web-server/static/js/common.js b/Software/web-server/static/js/common.js index c2f73938..7b6afb3d 100644 --- a/Software/web-server/static/js/common.js +++ b/Software/web-server/static/js/common.js @@ -1,4 +1,6 @@ // Common functionality for all PiTrac pages +/* global showStatusMessage */ +/* exported setTheme, controlPiTrac, requireStrobeSafe */ if (typeof lucide !== 'undefined') { lucide.createIcons(); diff --git a/Software/web-server/static/js/config.js b/Software/web-server/static/js/config.js index 6af9b7c9..3d71ca29 100644 --- a/Software/web-server/static/js/config.js +++ b/Software/web-server/static/js/config.js @@ -1,8 +1,7 @@ // Configuration Manager JavaScript -/* global saveChanges, resetAll, reloadConfig, showDiff, - filterConfig, closeModal, setTheme, openImage, resetShot, controlPiTrac, - resetValueFromDiff, resetAllFromDiff, clearSearch, searchConfig, - resetToDefault */ +/* exported saveChanges, resetAll, reloadConfig, showDiff, filterConfig, + closeModal, resetValueFromDiff, resetAllFromDiff, clearSearch, + searchConfig, resetToDefault */ let currentConfig = {}; let defaultConfig = {}; @@ -1402,18 +1401,14 @@ async function detectAndSetCameras(targetKey = null) { } const result = await response.json(); - console.log('Camera detection result:', result); if (result.success && result.cameras && result.cameras.length > 0) { const config = result.configuration; - console.log('Configuration:', config); if (targetKey === 'cameras.slot1.type') { const input = document.querySelector('.config-input[data-key="cameras.slot1.type"]'); - console.log('Found slot1 input:', input); if (input) { const typeValue = String(config.slot1.type); - console.log('Setting slot1 to:', typeValue); input.value = typeValue; // Trigger change event for select elements @@ -1427,10 +1422,8 @@ async function detectAndSetCameras(targetKey = null) { updateStatus(`Camera 1 detected: Type ${config.slot1.type}`, 'success'); } else if (targetKey === 'cameras.slot2.type') { const input = document.querySelector('.config-input[data-key="cameras.slot2.type"]'); - console.log('Found slot2 input:', input); if (input) { const typeValue = String(config.slot2.type); - console.log('Setting slot2 to:', typeValue); input.value = typeValue; // Trigger change event for select elements @@ -1445,11 +1438,9 @@ async function detectAndSetCameras(targetKey = null) { } else { const input1 = document.querySelector('.config-input[data-key="cameras.slot1.type"]'); const input2 = document.querySelector('.config-input[data-key="cameras.slot2.type"]'); - console.log('Found inputs - slot1:', input1, 'slot2:', input2); if (input1) { const typeValue = String(config.slot1.type); - console.log('Setting slot1 to:', typeValue); input1.value = typeValue; // Trigger change event for select elements @@ -1463,7 +1454,6 @@ async function detectAndSetCameras(targetKey = null) { if (input2) { const typeValue = String(config.slot2.type); - console.log('Setting slot2 to:', typeValue); input2.value = typeValue; // Trigger change event for select elements diff --git a/Software/web-server/static/js/dashboard.js b/Software/web-server/static/js/dashboard.js index 1acc339a..51748612 100644 --- a/Software/web-server/static/js/dashboard.js +++ b/Software/web-server/static/js/dashboard.js @@ -1,3 +1,4 @@ +/* exported openImage, resetShot, showStatusMessage */ // Dashboard-specific functionality (theme and dropdown handled by common.js) let ws = null; let piTracRunning = false; diff --git a/Software/web-server/static/js/testing.js b/Software/web-server/static/js/testing.js index 70dbb73e..8de11e3c 100644 --- a/Software/web-server/static/js/testing.js +++ b/Software/web-server/static/js/testing.js @@ -1,4 +1,4 @@ -/* globals setTheme, closeModal */ +/* global requireStrobeSafe */ const runningTools = new Set(); let outputBuffer = []; diff --git a/Software/web-server/static/js/update.js b/Software/web-server/static/js/update.js index 03754ca9..cd015eaa 100644 --- a/Software/web-server/static/js/update.js +++ b/Software/web-server/static/js/update.js @@ -1,6 +1,6 @@ +/* exported checkForUpdates, switchBranch, cancelUpdate, clearLog */ let currentBranch = null; let selectedBranch = null; -let isUpdating = false; async function loadBranches() { try { @@ -225,7 +225,6 @@ function clearLog() { } function setUpdatingState(updating) { - isUpdating = updating; document.getElementById('checkBtn').disabled = updating; document.getElementById('updateBtn').disabled = updating; document.getElementById('switchBtn').disabled = updating; diff --git a/Software/web-server/static/vendor/daisyui-5.7.47.css b/Software/web-server/static/vendor/daisyui-5.7.47.css new file mode 100644 index 00000000..e1553710 --- /dev/null +++ b/Software/web-server/static/vendor/daisyui-5.7.47.css @@ -0,0 +1 @@ +/*! 🌼 daisyUI 5.7.47 - MIT License */ @layer base{:root,:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}@property --radialprogress{syntax:"";inherits:true;initial-value:0%}@property --aura-angle{syntax:"";inherits:false;initial-value:0deg}:root{--page-has-backdrop:var(--page-scroll-lock) 1;--page-scroll-bg:var(--page-scroll-lock) color-mix(in srgb, var(--root-bg,#0000), oklch(0% 0 0) calc(var(--page-has-backdrop,0) * 40%));background-image:var(--page-scroll-lock) linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000));transition:var(--page-scroll-lock) background-color .3s ease-out;animation:var(--page-scroll-lock) set-page-has-scroll forwards;animation-timeline:var(--page-scroll-lock) scroll();--page-has-scroll:initial;scrollbar-gutter:var(--page-has-scroll) var(--page-scroll-lock) stable}@keyframes set-page-has-scroll{0%,to{--page-has-scroll: }}:root{scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) #0000;--page-scroll-lock:initial;--page-overflow:var(--page-scroll-lock) hidden}:root:not(span){overflow:var(--page-overflow)}:root,[data-theme]{background-color:var(--root-bg);color:var(--color-base-content)}:root{background-color:var(--page-scroll-bg,var(--root-bg))}:where(:root,[data-theme]){--root-bg:var(--color-base-100)}*,:after,::backdrop,:before,::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}:host,html{line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");-webkit-tap-highlight-color:#0000}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}table{text-indent:0;border-color:inherit;border-collapse:collapse}::file-selector-button,button,input,optgroup,select,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;background:0 0}input:where(:not([type=button],[type=reset],[type=submit])),select,textarea{border-width:1px}::file-selector-button,button,input:where([type=button],[type=reset],[type=submit]){appearance:button}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}::-webkit-search-decoration{-webkit-appearance:none}menu,ol,ul{list-style:none}textarea{resize:vertical}::placeholder{opacity:1;color:color-mix(in oklch, currentColor 50%, #0000)}audio,canvas,embed,iframe,img,object,svg,video{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E")}}@layer utilities{@layer daisyui.l1.l2.l3{.tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-radius:min(var(--radius-field), .8rem);--tt-overlap:clamp(1px, calc(var(--radius-field) / 8), 2px);--tt-tail:calc(100% + var(--tt-overlap) + .25rem);--tt-tail-off:max(.5rem, var(--tt-radius));display:inline-block;position:relative}.tooltip>.tooltip-content,.tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;font-weight:400;line-height:1.25;position:absolute}.tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;mask-position:-1px 0;mask-repeat:no-repeat;mask-image:var(--mask-tooltip);transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%;display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.tooltip>.tooltip-content,.tooltip[data-tip]:before,.tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem;@media (prefers-reduced-motion:no-preference){transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}.table{border-collapse:separate;--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(:is(.table tr.row-hover),.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.table :where(thead,tfoot){white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);font-size:.875rem;font-weight:600}.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}.table :where(.table-pin-rows thead){z-index:1;position:sticky;top:0}.table :where(.table-pin-rows tfoot){z-index:1;position:sticky;bottom:0}.table :where(.table-pin-rows :is(thead,tfoot) tr){background-color:var(--color-base-100)}.table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}.file-input{border:var(--border) solid #0000;cursor:pointer;appearance:none;background-color:var(--color-base-100);vertical-align:middle;-webkit-user-select:none;user-select:none;width:clamp(3rem,20rem,100%);height:var(--size);border-color:var(--input-color);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-field,.25rem) * 10);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));align-items:center;padding-inline-end:.75rem;font-size:.875rem;line-height:2;display:inline-flex}.file-input::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;height:calc(100% + var(--border) * 2);margin-inline-end:1rem;margin-block:calc(var(--border) * -1);color:var(--btn-fg);border-width:var(--border);border-style:solid;border-color:var(--btn-border);background-color:var(--btn-bg);background-size:calc(var(--noise) * 100%);background-image:var(--fx-noise);text-shadow:0 .5px oklch(1 0 0 / calc(var(--depth) * .15));box-shadow:0 .5px 0 .5px color-mix(in oklab, color-mix(in oklab, white 30%, var(--btn-bg)) calc(var(--depth) * 20%), #0000) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-border:color-mix(in oklab, var(--btn-bg), #000 5%);--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000);border-start-start-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-start-radius:calc(var(--join-es,var(--radius-field) - var(--border)));margin-inline-start:calc(var(--border) * -1);padding-inline:1rem;font-size:.875rem;font-weight:600}.file-input:focus{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) 10%, #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}.carousel{scroll-snap-type:x mandatory;scrollbar-width:none;display:inline-flex;overflow-x:scroll}@media (prefers-reduced-motion:no-preference){.carousel{scroll-behavior:smooth}}.carousel::-webkit-scrollbar{display:none}.carousel-item{box-sizing:content-box;scroll-snap-align:start;flex:none;display:flex}.steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}&>.step-icon,&:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}&:first-child:before{--tw-content:none;content:var(--tw-content)}&[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}}.dropdown{position-area:var(--anchor-v,block-end) var(--anchor-h,span-inline-end);display:inline-block;position:relative}.dropdown>:not(:has(~[class*=dropdown-content])):focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.dropdown .dropdown-content{position:absolute}.dropdown.dropdown-close .dropdown-content,.dropdown:not(details,.dropdown-open,.dropdown-hover:hover,:focus-within) .dropdown-content,.dropdown.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible)~.dropdown-content{transform-origin:top;opacity:0;display:none;scale:.95}.dropdown[popover],.dropdown .dropdown-content{z-index:999;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:opacity,scale,display,overlay;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s dropdown}}@starting-style{.dropdown[popover],.dropdown .dropdown-content{opacity:0;scale:.95}}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within)>[tabindex]:first-child{pointer-events:none}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within) .dropdown-content,.dropdown:not(.dropdown-close).dropdown-hover:hover .dropdown-content{opacity:1;scale:1}.dropdown:is(details){overflow:revert-layer;& summary{&::-webkit-details-marker{display:none}}}.dropdown:where([popover]){background:0 0}.dropdown[popover]{color:inherit;position:fixed;@supports not (position-area:bottom){margin:auto;&.dropdown-close,&.dropdown-open:not(:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}&::backdrop{background-color:oklab(0% none none/.3)}}&.dropdown-close,&:not(.dropdown-open,:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}}.navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.navbar-start{justify-content:flex-start;align-items:center;width:50%;display:inline-flex}.navbar-center{flex-shrink:0;align-items:center;display:inline-flex}.navbar-end{justify-content:flex-end;align-items:center;width:50%;display:inline-flex}.toast{inset-inline:auto 1rem;translate:var(--toast-x,0) var(--toast-y,0);background-color:#0000;flex-direction:column;gap:.5rem;width:max-content;max-width:calc(100vw - 2rem);display:flex;position:fixed;top:auto;bottom:1rem}@media (prefers-reduced-motion:no-preference){.toast>*{animation:.25s ease-out toast}}.range{-webkit-appearance:none;appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:color-mix(in oklab, currentColor 10%, #0000);--range-fill-x:calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2));--range-fill-y:0;--range-fill-spread:calc(100cqw * var(--range-fill));cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden;container-type:inline-size}[dir=rtl] .range{--range-dir:-1}.range:focus{outline:none}.range:focus-visible{outline-offset:2px;outline:2px solid}.range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.range::-webkit-slider-runnable-track{border:1px solid}}.range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;-webkit-appearance:none;appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);position:relative;inset-block-start:50%;transform:translateY(-50%)}.range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.range::-moz-range-track{border:1px solid}}.range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);background-color:currentColor;position:relative}.range:disabled{cursor:not-allowed;opacity:.3}.hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.hero>*{grid-row-start:1;grid-column-start:1}.hero-overlay{background-color:color-mix(in oklab, var(--color-neutral) 50%, transparent);grid-row-start:1;grid-column-start:1;width:100%;height:100%}.hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}.input{appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;--size:calc(var(--size-field,.25rem) * var(--in-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);cursor:text;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}.input input{appearance:none;background-color:#0000;border:none;width:100%;height:100%;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&::-webkit-calendar-picker-indicator{inset-inline-end:-.15em}}.input::-webkit-inner-spin-button{margin-inline-end:-10px}.input::-webkit-calendar-picker-indicator{inset-inline-end:.75em}input.input,.input input{text-align:start;display:inline-flex;position:relative;&[type=url],&[type=tel],&[type=email],&[type=number]{direction:ltr}&::-webkit-datetime-edit,&::-webkit-date-and-time-value{min-height:100%;text-align:inherit;align-items:center;display:grid}&::-webkit-inner-spin-button{margin-block:calc(.25rem * var(--spin-my,-3))}&::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute}&::-webkit-color-swatch-wrapper{padding-block:.25rem}}.input:focus,.input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.input:focus,.input:focus-within{--font-size:1rem}}}.collapse-content{--overflow-delay:0s;content-visibility:hidden;min-height:0;cursor:unset;grid-row-start:2;grid-column-start:1;padding-left:1rem;padding-right:1rem;overflow:clip}@supports not (content-visibility:hidden){.collapse-content{visibility:hidden}}@media (prefers-reduced-motion:no-preference){.collapse-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out}}details>.collapse-content{content-visibility:visible}.collapse-title{padding:1rem;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out;position:relative}.list{flex-direction:column;font-size:.875rem;display:flex}.list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent);position:absolute;bottom:0}.hover-3d{perspective:75rem;--transform:0, 0;--shine:100% 100%;--shadow:0rem 0rem 0rem;--ease:linear(0, .931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);filter:drop-shadow(var(--shadow) .1rem #00000003) drop-shadow(var(--shadow) .2rem #00000003) drop-shadow(var(--shadow) .3rem #00000003) drop-shadow(var(--shadow) .4rem #00000003);transition:filter .4s ease-out;display:inline-grid}.hover-3d>:nth-child(n+2){isolation:isolate;z-index:1}.hover-3d>:first-child{transform:rotate3d(var(--transform), 0, 10deg);transition:transform var(--ease) .5s, scale var(--ease) .5s, outline-color ease-out .5s;outline-offset:-1px;outline:.5px solid #0000;grid-area:1/1/4/4;overflow:hidden;&:before{content:"";pointer-events:none;z-index:1;opacity:0;filter:blur(.75rem);width:33.3333%;height:33.3333%;scale:5;translate:var(--shine);background-image:radial-gradient(circle,#fff3 10%,#0000 50%);transition:translate .4s ease-out,opacity .4s ease-out;position:absolute}}.hover-3d:hover{--ease:linear(0, .708 15.2%, .927 23.6%, 1.067 33%, 1.12 41%, 1.13 50.2%, 1.019 83.2%, 1);&>:first-child{outline-color:#fff1;&:before,&:after{opacity:1}}}.hover-3d>:nth-child(n+2){scale:1.2}.hover-3d>:nth-child(2){grid-area:1/1/2/2}.hover-3d>:nth-child(3){grid-area:1/2/2/3}.hover-3d>:nth-child(4){grid-area:1/3/2/4}.hover-3d>:nth-child(5){grid-area:2/1/3/2}.hover-3d>:nth-child(6){grid-area:2/3/3/4}.hover-3d>:nth-child(7){grid-area:3/1/4/2}.hover-3d>:nth-child(8){grid-area:3/2/4/3}.hover-3d>:nth-child(9){grid-area:3/3/4/4}.hover-3d:hover>:first-child{scale:1.05}.hover-3d:has(>:nth-child(2):hover){--transform:-1, 1;--shine:0% 0%;--shadow:-.5rem -.5rem}.hover-3d:has(>:nth-child(3):hover){--transform:-1, 0;--shine:100% 0%;--shadow:0rem -.5rem}.hover-3d:has(>:nth-child(4):hover){--transform:-1, -1;--shine:200% 0%;--shadow:.5rem -.5rem}.hover-3d:has(>:nth-child(5):hover){--transform:0, 1;--shine:0% 100%;--shadow:-.5rem 0rem}.hover-3d:has(>:nth-child(6):hover){--transform:0, -1;--shine:200% 100%;--shadow:.5rem 0rem}.hover-3d:has(>:nth-child(7):hover){--transform:1, 1;--shine:0% 200%;--shadow:-.5rem .5rem}.hover-3d:has(>:nth-child(8):hover){--transform:1, 0;--shine:100% 200%;--shadow:0rem .5rem}.hover-3d:has(>:nth-child(9):hover){--transform:1, -1;--shine:200% 200%;--shadow:.5rem .5rem}.fab{pointer-events:none;inset-inline-end:1rem;z-index:999;white-space:nowrap;-webkit-user-select:none;user-select:none;flex-direction:column-reverse;align-items:flex-end;gap:.5rem;font-size:.875rem;line-height:1.25rem;display:flex;position:fixed;bottom:1rem}.fab>*{pointer-events:auto;align-items:center;gap:.5rem;display:flex;&:hover,&:has(:focus-visible){z-index:1}}.fab>[tabindex]:first-child{transition-property:opacity,visibility,rotate;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:grid;position:relative}.fab .fab-close,.fab .fab-main-action{inset-inline-end:0;position:absolute;bottom:0}:is(.fab:focus-within:has(.fab-close),.fab:focus-within:has(.fab-main-action))>[tabindex]{opacity:0;rotate:90deg}.fab>:nth-child(n+2){visibility:hidden;--tw-scale-x:80%;--tw-scale-y:80%;--tw-scale-z:80%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:0;transition-property:opacity,scale,visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);&.fab-main-action,&.fab-close{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.fab>:nth-child(3){transition-delay:30ms}.fab>:nth-child(4){transition-delay:60ms}.fab>:nth-child(5){transition-delay:90ms}.fab>:nth-child(6){transition-delay:.12s}.fab:focus-within>[tabindex]:first-child{pointer-events:none}.fab:focus-within>:nth-child(n+2){visibility:visible;--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}.fab-flower{--position:0rem;display:grid}.fab-flower>:first-child,.fab-flower>.fab-main-action,.fab-flower>.fab-close{--position:0rem}.fab-flower>*{--degree:180deg;--flip-degree:calc(180deg - var(--degree));transform:translateX(calc(cos(var(--degree)) * var(--position))) translateY(calc(sin(var(--degree)) * -1 * var(--position)));grid-area:1/1;[dir=rtl] &{transform:translateX(calc(cos(var(--flip-degree)) * var(--position))) translateY(calc(sin(var(--flip-degree)) * -1 * var(--position)))}}.fab-flower>:nth-child(n+7){display:none}.fab-flower:has(>:nth-child(3)){--position:140%;&>:nth-child(3){--degree:135deg}}.fab-flower:has(>:nth-child(4)){--position:140%;&>:nth-child(3){--degree:165deg}&>:nth-child(4){--degree:105deg}}.fab-flower:has(>:nth-child(5)){--position:180%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:135deg}&>:nth-child(5){--degree:90deg}}.fab-flower:has(>:nth-child(6)){--position:220%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:150deg}&>:nth-child(5){--degree:120deg}&>:nth-child(6){--degree:90deg}}.fab-flower:not(:has(.fab-main-action,.fab-close))>:nth-child(n+6){display:none}.fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(2)){--position:140%;&>:nth-child(2){--degree:135deg}}.fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(3)){--position:140%;&>:nth-child(2){--degree:165deg}&>:nth-child(3){--degree:105deg}}.fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(4)){--position:180%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:135deg}&>:nth-child(4){--degree:90deg}}.fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(5)){--position:220%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:150deg}&>:nth-child(4){--degree:120deg}&>:nth-child(5){--degree:90deg}}.stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.stack>*{width:100%;height:100%;&:nth-child(n+2){opacity:.7;width:100%}&:nth-child(2){z-index:2;opacity:.9}&:first-child{z-index:3;width:100%}}.rating{vertical-align:middle;--size:var(--size-selector,.25rem) * 6;display:inline-flex;position:relative}.rating input{cursor:pointer;appearance:none}.rating *{background-color:var(--color-base-content);opacity:.2;width:calc(var(--size) * 1);height:calc(var(--size));border-radius:0;@media (prefers-reduced-motion:no-preference){animation:.25s ease-out rating}}.rating .rating-hidden{background-color:#0000;width:.5rem}.rating :checked,.rating [aria-checked=true],.rating [aria-current=true],.rating :has(~:checked,~[aria-checked=true],~[aria-current=true]){opacity:1}.rating :focus-visible{scale:1.1;@media (prefers-reduced-motion:no-preference){transition:scale .2s ease-out}}.rating :active:focus{animation:none;scale:1.1}.aura{--aura-padding:.125rem;padding:var(--aura-padding);border-radius:calc(var(--aura-padding) + var(--aura-radius,var(--radius-box)));animation:aura var(--tw-duration,6s) linear infinite;display:inline-block;position:relative}@media (prefers-reduced-motion:reduce){.aura{animation-duration:calc(var(--tw-duration,6s) * 4)}}.aura{background-image:conic-gradient(from var(--aura-angle), transparent 225deg, currentColor)}.aura:has(>.card,>.alert){--aura-radius:var(--radius-box)}.aura:has(>.btn,>.input,>.select){--aura-radius:var(--radius-field)}.aura:has(>.checkbox,>.toggle,>.badge){--aura-radius:var(--radius-selector)}.aura:before,.aura:after{animation:inherit;background-color:inherit;background-image:inherit;border-radius:inherit;z-index:0;opacity:.7;filter:blur(.25rem);content:"";width:100%;height:100%;display:block;position:absolute;top:50%;left:50%;translate:-50% -50%}.aura:after{opacity:.3;filter:blur(1rem)}.aura>*{z-index:1;position:relative}.diff-resizer{isolation:isolate;z-index:2;resize:horizontal;opacity:0;cursor:ew-resize;transform-origin:100% 100%;clip-path:inset(calc(100% - .75rem) 0 0 calc(100% - .75rem));grid-row-start:2;grid-column-start:1;width:50cqi;min-width:1rem;max-width:calc(100cqi - 1rem);height:.75rem;transition:min-width .3s ease-out,max-width .3s ease-out;position:relative;overflow:hidden;transform:scaleY(5)translate(.32rem,50%)}.diff-item-2{grid-row:1/span 3;grid-column-start:1;position:relative}.diff-item-2:after{pointer-events:none;z-index:2;background-color:color-mix(in oklab, var(--color-base-100) 98%, transparent);border:2px solid var(--color-base-100);content:"";outline:2px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);outline-offset:-3px;width:1.2rem;height:1.8rem;transition:height .3s linear(0, .931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);border-radius:3.40282e38px;position:absolute;top:50%;bottom:0;right:1px;translate:50% -50%;box-shadow:0 0 0 2px #0000002a}.diff-item-2>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.diff-item-2:after{--tw-content:none;content:var(--tw-content)}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.diff-item-2:after{--tw-content:none;content:var(--tw-content)}}}.diff-item-1{z-index:1;border-right:2px solid var(--color-base-100);grid-row:1/span 3;grid-column-start:1;position:relative;overflow:hidden;box-shadow:0 0 0 2px #0000002a}.diff-item-1:focus-visible{--tw-outline-style:none;outline-style:none}.diff-item-1>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}.card{border-radius:var(--radius-box);outline-offset:2px;outline:2px solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.card:focus-visible,.card[aria-checked=true],.card:has(>:checked,>:is([type=checkbox],[type=radio]):focus-visible){outline-color:currentColor}.card:has(>:checked:focus-visible),.card[aria-checked=true]:focus-visible,.card[aria-checked=true]:has(>:is([type=checkbox],[type=radio]):focus-visible){outline-width:4px}.card:has(>:is([type=checkbox],[type=radio])){cursor:pointer;user-select:none}.card>:is([type=checkbox],[type=radio]){appearance:none}.card-title{font-size:var(--cardtitle-fs,1.125rem);align-items:center;gap:.5rem;font-weight:600;display:flex}.card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.card-actions{flex-wrap:wrap;align-items:flex-start;gap:.5rem;display:flex}.label{white-space:nowrap;color:color-mix(in oklab, currentcolor 60%, transparent);align-items:center;gap:.375rem;display:inline-flex}.label:has(input){cursor:pointer}.label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex;&:first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:-.75rem .75rem}&:last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:.75rem -.75rem}}.floating-label{display:flex;position:relative}.floating-label ::placeholder{transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out}.floating-label>span{z-index:1;background-color:var(--color-base-100);opacity:0;font-size:var(--font-size,.875rem);inset-inline-start:.75rem;top:calc(var(--size-field,.25rem) * var(--top-mul,5));pointer-events:none;border-radius:2px;padding-inline:.25rem;line-height:1;transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out;position:absolute;translate:0 -50%}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) ::placeholder{opacity:0;pointer-events:auto;top:0;translate:-12.5% calc(-50% - .125em);scale:.75}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) :dir(rtl)::placeholder{translate:12.5% calc(-50% - .125em)}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown)))>span{opacity:1;pointer-events:auto;z-index:2;top:0;translate:-12.5% calc(-50% - .125em);scale:.75;&:dir(rtl){translate:12.5% calc(-50% - .125em)}}.floating-label:has(:disabled,[disabled])>span{opacity:0}.tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.tab:is(.tabs>.tab){cursor:pointer;appearance:none;text-align:center;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative;&:hover{@media (hover:hover){color:var(--color-base-content)}}--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem;&:is(input[type=radio]){min-width:fit-content;&:after{--tw-content:attr(aria-label);content:var(--tw-content)}}&:is(label){position:relative;& input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}}&:checked,&:is(label:has(:checked)),&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){&+.tab-content{display:block}}&:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}&:not(input):empty{cursor:default;flex-grow:1}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}&[disabled]{pointer-events:none;opacity:.4}}.tab-content{--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box);--tabcontent-order:1;width:100%;height:calc(100% - var(--tab-height) + var(--border));margin:var(--tabcontent-margin);order:1;order:var(--tabcontent-order);border-color:#0000;border-width:var(--border);border-start-start-radius:var(--tabcontent-radius-ss);border-start-end-radius:var(--tabcontent-radius-se);border-end-end-radius:var(--tabcontent-radius-ee);border-end-start-radius:var(--tabcontent-radius-es);display:none}.select{appearance:none;background-color:var(--color-base-100);vertical-align:middle;--size:calc(var(--size-field,.25rem) * var(--sl-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;display:inline-flex;position:relative;overflow:hidden}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}.select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:not(:last-child){background-image:none;margin-inline-end:-1.375rem}}.select:focus,.select:focus-within,.select:open{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}.select:open{background-image:linear-gradient(135deg,#0000 50%,currentColor 50%),linear-gradient(45deg,currentColor 50%,#0000 50%)}@supports (appearance:base-select){:is(.select,.select select),:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) selectedcontent{text-overflow:ellipsis;white-space:nowrap;width:100%;overflow:hidden}:is(.select,.select select) optgroup{padding-top:.5em;& option{&:first-child{margin-top:.5em}}}:is(.select,.select select) option{border-radius:var(--radius-field);padding-block:.375rem;padding-inline:calc(.25rem * var(--option-px,3));white-space:normal;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);&:not(:disabled){&:hover,&:focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}}}[dir=rtl] .select::picker(select),[dir=rtl] .select select::picker(select){translate:.5rem}.mask{vertical-align:middle;display:inline-block;mask-position:50%;mask-size:contain;mask-repeat:no-repeat}.badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));flex-shrink:0;justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}.mockup-code{border-radius:var(--radius-box);background-color:var(--color-neutral);color:var(--color-neutral-content);direction:ltr;padding-block:1.25rem;font-size:.875rem;position:relative;overflow:auto hidden}.mockup-code:before{content:"";opacity:.3;border-radius:3.40282e38px;width:.75rem;height:.75rem;margin-bottom:1rem;display:block;box-shadow:1.4em 0,2.8em 0,4.2em 0}.mockup-code pre{width:max-content;min-width:100%;padding-right:1.25rem;&:before{content:"";margin-right:2ch}&[data-prefix]{&:before{--tw-content:attr(data-prefix);content:var(--tw-content);text-align:right;opacity:.5;width:2rem;display:inline-block}}}.mockup-window{border-radius:var(--radius-box);flex-direction:column;padding-top:1.25rem;display:flex;position:relative;overflow:auto hidden}.mockup-window:before{content:"";aspect-ratio:1;opacity:.3;border-radius:3.40282e38px;flex-shrink:0;align-self:flex-start;height:.75rem;margin-bottom:1rem;display:block;box-shadow:1.4em 0,2.8em 0,4.2em 0}[dir=rtl] .mockup-window:before{align-self:flex-end}.mockup-window pre[data-prefix]:before{--tw-content:attr(data-prefix);content:var(--tw-content);text-align:right;display:inline-block}.mockup-browser{border-radius:var(--radius-box);position:relative;overflow:auto hidden}.mockup-browser pre[data-prefix]:before{--tw-content:attr(data-prefix);content:var(--tw-content);text-align:right;display:inline-block}.mockup-browser .mockup-browser-toolbar{align-items:center;width:100%;margin-block:.75rem;padding-right:1.4em;display:inline-flex;&:where(:dir(rtl),[dir=rtl],[dir=rtl] *){flex-direction:row-reverse}&:before{content:"";aspect-ratio:1;opacity:.3;border-radius:3.40282e38px;height:.75rem;margin-right:4.8rem;display:inline-block;box-shadow:1.4em 0,2.8em 0,4.2em 0}& .input{background-color:var(--color-base-200);text-overflow:ellipsis;white-space:nowrap;direction:ltr;align-items:center;gap:.5rem;height:100%;margin-inline:auto;font-size:.75rem;display:flex;overflow:hidden;&:before{content:"";opacity:.5;background-color:currentColor;width:1rem;height:1rem;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z' clip-rule='evenodd' /%3E%3C/svg%3E") 50%/contain no-repeat}}}.mockup-phone{aspect-ratio:462/978;background-color:#000;border:5px solid #6b6b6b;border-radius:65px;justify-items:center;width:100%;max-width:462px;padding:6px;display:inline-grid;overflow:hidden}@supports (corner-shape:superellipse(1.45)){.mockup-phone{corner-shape:superellipse(1.45);border-radius:90px}}.mockup-phone-camera{z-index:1;background:#000;border-radius:17px;grid-area:1/1/1/1;width:28%;height:3.7%;margin-top:3%}.mockup-phone-display{border-radius:54px;grid-area:1/1/1/1;width:100%;height:100%;overflow:hidden}@supports (corner-shape:superellipse(1.87)){.mockup-phone-display{corner-shape:superellipse(1.87);border-radius:101px}}.mockup-phone-display>img{object-fit:cover;width:100%;height:100%}.otp{font-family:var(--font-mono,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);clip-path:inset(-3.5px 3.5px -3.5px -3.5px);border-radius:var(--radius-field);gap:var(--otp-gap);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);--otp-ch:1ch;direction:ltr;font-size:1.75rem;display:inline-flex;position:relative}@supports (font:-apple-system-body){.otp{--otp-ch:.618164em}}.otp{--otp-gap:calc(var(--otp-ch) * .5);--otp-w:calc(var(--otp-ch) * 2);--otp-size:calc(var(--size-field,.25rem) * 10);--stride:calc(var(--otp-w) + var(--otp-gap))}.otp>input{pointer-events:none;z-index:1;appearance:none;border-style:var(--tw-border-style);outline-style:var(--tw-outline-style);field-sizing:content;padding:0;text-indent:1px;letter-spacing:calc(var(--stride) - var(--otp-ch));font-variant-numeric:tabular-nums;background-color:#0000;border-width:0;outline-width:0;margin:0;padding-inline-start:calc(var(--otp-ch) * .5 - 1px);line-height:1;inset-inline-start:0;&::selection{color:color-mix(in oklab, var(--color-base-content) 20%, #0000);background-color:#0000}&:valid{caret-color:#0000}}.otp:has(>span:first-child){width:calc(var(--stride) * 1)}.otp:has(>span:nth-child(2)){width:calc(var(--stride) * 2)}.otp:has(>span:nth-child(3)){width:calc(var(--stride) * 3)}.otp:has(>span:nth-child(4)){width:calc(var(--stride) * 4)}.otp:has(>span:nth-child(5)){width:calc(var(--stride) * 5)}.otp:has(>span:nth-child(6)){width:calc(var(--stride) * 6)}.otp:has(>span:nth-child(7)){width:calc(var(--stride) * 7)}.otp:has(>span:nth-child(8)){width:calc(var(--stride) * 8)}.otp>span{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inline-size:var(--otp-w);block-size:var(--otp-size);background-color:var(--color-base-100);border:var(--border) solid var(--input-color);border-radius:inherit;outline-offset:1px;box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;outline:2px solid #0000;transition:border-color .2s;display:flex;position:absolute;&:first-child{left:0;@supports (font:-apple-system-body){left:1px}}&:nth-child(2){left:calc(var(--stride) * 1);transition-delay:20ms}&:nth-child(3){left:calc(var(--stride) * 2);transition-delay:40ms}&:nth-child(4){left:calc(var(--stride) * 3);transition-delay:60ms}&:nth-child(5){left:calc(var(--stride) * 4);transition-delay:80ms}&:nth-child(6){left:calc(var(--stride) * 5);transition-delay:.1s}&:nth-child(7){left:calc(var(--stride) * 6);transition-delay:.12s}&:nth-child(8){left:calc(var(--stride) * 7);transition-delay:.14s}}.otp:has(input:valid:focus)>span{outline:2px solid var(--input-color);outline-offset:1px}.otp:has(input:valid:focus):after{opacity:0}.otp:after{content:"";width:var(--otp-w);height:var(--otp-size);border-radius:var(--radius-field);outline-offset:1px;z-index:10;outline:2px solid #0000;flex-shrink:0;margin-inline-start:calc(-1 * (var(--otp-gap) + var(--otp-ch) * .5))}.otp:focus-within{--input-color:var(--color-base-content);&>span{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}&:after{outline-color:var(--input-color)}}.otp:has(>input:disabled){cursor:not-allowed;&>input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}&>span{border-color:var(--color-base-200);background-color:var(--color-base-200);box-shadow:none}}.loading{pointer-events:none;aspect-ratio:1;vertical-align:middle;width:calc(var(--size-selector,.25rem) * 6);background-color:currentColor;flex-shrink:0;display:inline-block;mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='8s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='6s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='6s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");mask-position:50%;mask-size:100%;mask-repeat:no-repeat}@media (prefers-reduced-motion:no-preference){.loading{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}}.status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent);vertical-align:middle;color:color-mix(in oklab, var(--color-black) 30%, transparent);--size:calc(var(--size-selector,.25rem) * 2);width:var(--size);height:var(--size);background-position:50%;background-repeat:no-repeat;background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);display:inline-block}.megamenu{width:unset;border-radius:calc(var(--radius-field) + var(--border));--mm-anchor:--mm1;--size:calc(var(--size-field,.25rem) * 10);background-color:#0000;align-items:center;display:flex;position:relative;overflow:visible}.megamenu [popovertarget]{isolation:isolate;cursor:pointer;padding-inline:var(--mm-p,1rem);height:var(--size);font-size:var(--fontsize,.875rem);border:var(--border) solid transparent;anchor-name:var(--mm-anchor);align-items:center;gap:.75rem;transition:background-color .2s ease-out,color .2s ease-out;display:flex;position:relative;&:focus-visible{outline:2px solid var(--color-base-content);outline-offset:2px}&:after{--tw-content:"";content:var(--tw-content);pointer-events:none;opacity:.25;width:.375rem;height:.375rem;transition:opacity .2s ease-out,rotate .2s ease-out;display:block;inset-inline-end:1.4rem;rotate:-135deg;box-shadow:inset 2px 2px}&:first-of-type{--mm-anchor:--mm1}&:nth-of-type(2){--mm-anchor:--mm2}&:nth-of-type(3){--mm-anchor:--mm3}&:nth-of-type(4){--mm-anchor:--mm4}&:nth-of-type(5){--mm-anchor:--mm5}&:nth-of-type(6){--mm-anchor:--mm6}&:nth-of-type(7){--mm-anchor:--mm7}&:nth-of-type(8){--mm-anchor:--mm8}&:nth-of-type(9){--mm-anchor:--mm9}&:nth-of-type(10){--mm-anchor:--mm10}}.megamenu:not(:has([popovertarget]:hover,[popover]:popover-open)) .megamenu-active{background-color:#0000}.megamenu:has([popovertarget]:hover):not(:has([popover]:popover-open)) .megamenu-active,.megamenu:has([popover]:popover-open) .megamenu-active{inset:anchor(var(--mm-anchor) top) anchor(var(--mm-anchor) end) anchor(var(--mm-anchor) bottom) anchor(var(--mm-anchor) start)}.megamenu:has([popovertarget]:first-of-type:hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:first-of-type:popover-open){--mm-anchor:--mm1}.megamenu:has([popovertarget]:nth-of-type(2):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(2):popover-open){--mm-anchor:--mm2}.megamenu:has([popovertarget]:nth-of-type(3):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(3):popover-open){--mm-anchor:--mm3}.megamenu:has([popovertarget]:nth-of-type(4):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(4):popover-open){--mm-anchor:--mm4}.megamenu:has([popovertarget]:nth-of-type(5):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(5):popover-open){--mm-anchor:--mm5}.megamenu:has([popovertarget]:nth-of-type(6):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(6):popover-open){--mm-anchor:--mm6}.megamenu:has([popovertarget]:nth-of-type(7):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(7):popover-open){--mm-anchor:--mm7}.megamenu:has([popovertarget]:nth-of-type(8):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(8):popover-open){--mm-anchor:--mm8}.megamenu:has([popovertarget]:nth-of-type(9):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(9):popover-open){--mm-anchor:--mm9}.megamenu:has([popovertarget]:nth-of-type(10):hover):not(:has([popover]:popover-open)),.megamenu:has([popover]:nth-of-type(10):popover-open){--mm-anchor:--mm10}.megamenu [popovertarget]:has(+[popover]:popover-open):after{opacity:1;rotate:45deg}.megamenu [popover]{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-base-300);opacity:0;border-radius:var(--radius-box);background-color:var(--color-base-100);position-area:block-end span-inline-end;max-height:100dvh;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;margin-top:.25rem;translate:0 -.5rem;scale:.98;&:popover-open{opacity:1;@starting-style{opacity:0}}}.megamenu:has([popover]:popover-open) [popover]{translate:0;scale:1}.megamenu-active{pointer-events:none;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);border-radius:var(--radius-field);transition:inset .3s linear(0, .5 10%, .9 30%, 1.05 50%, 1.1 75%, 1), background-color .2s ease-out;position:absolute}.countdown{display:inline-flex}.countdown>*{visibility:hidden;--value-v:calc(mod(max(0, var(--value)), 1000));--value-hundreds:calc(round(to-zero, var(--value-v) / 100, 1));--value-tens:calc(round(to-zero, mod(var(--value-v), 100) / 10, 1));--value-ones:calc(mod(var(--value-v), 100));--show-hundreds:clamp(clamp(0, var(--digits,1) - 2, 1), var(--value-hundreds), 1);--show-tens:clamp(clamp(0, var(--digits,1) - 1, 1), var(--value-tens) + var(--show-hundreds), 1);--first-digits:calc(round(to-zero, var(--value-v) / 10, 1));height:1em;width:calc(1ch + var(--show-tens) * 1ch + var(--show-hundreds) * 1ch);direction:ltr;transition:width .4s ease-out .2s;display:inline-block;position:relative;overflow-y:clip;&:before,&:after{visibility:visible;--tw-content:"00\a 01\a 02\a 03\a 04\a 05\a 06\a 07\a 08\a 09\a 10\a 11\a 12\a 13\a 14\a 15\a 16\a 17\a 18\a 19\a 20\a 21\a 22\a 23\a 24\a 25\a 26\a 27\a 28\a 29\a 30\a 31\a 32\a 33\a 34\a 35\a 36\a 37\a 38\a 39\a 40\a 41\a 42\a 43\a 44\a 45\a 46\a 47\a 48\a 49\a 50\a 51\a 52\a 53\a 54\a 55\a 56\a 57\a 58\a 59\a 60\a 61\a 62\a 63\a 64\a 65\a 66\a 67\a 68\a 69\a 70\a 71\a 72\a 73\a 74\a 75\a 76\a 77\a 78\a 79\a 80\a 81\a 82\a 83\a 84\a 85\a 86\a 87\a 88\a 89\a 90\a 91\a 92\a 93\a 94\a 95\a 96\a 97\a 98\a 99\a ";content:var(--tw-content);font-variant-numeric:tabular-nums;white-space:pre;text-align:end;direction:rtl;transition:all 1s cubic-bezier(1,0,0,1),width .2s ease-out .2s,opacity .2s ease-out .2s;position:absolute;overflow-x:clip}&:before{width:calc(1ch + var(--show-hundreds) * 1ch);top:calc(var(--first-digits) * -1em);opacity:var(--show-tens);inset-inline-end:0}&:after{width:1ch;top:calc(var(--value-ones) * -1em);inset-inline-start:0}}.filter{flex-wrap:wrap;display:flex}.filter [type=radio]{width:auto}.filter input{opacity:1;transition:visibility .1s allow-discrete, margin .1s, opacity .3s, padding .3s, border-width .1s;overflow:hidden;scale:1;&.filter-reset{aspect-ratio:1;&:after{--tw-content:"×";content:var(--tw-content)}}}.filter>input:not(:last-child),.filter>:not(:last-child) input{margin-inline-end:.25rem}.indicator{width:max-content;display:inline-flex;position:relative}.indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}.fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.fieldset-legend{color:var(--color-base-content);margin-bottom:-.25rem;justify-content:space-between;align-items:center;gap:.5rem;margin-inline-end:auto;padding-block:.5rem;font-weight:600;display:flex}.fieldset-label{color:color-mix(in oklab, var(--color-base-content) 60%, transparent);align-items:center;gap:.375rem;display:flex}.fieldset-label:has(input){cursor:pointer}.divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);flex-direction:row;align-self:stretch;align-items:center;display:flex}.divider:before,.divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.divider:before,.divider:after{border:.5px solid}}.divider:not(:empty){gap:1rem}.toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000);--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;transition:color .3s,grid-template-columns .2s;display:inline-grid;position:relative}.toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s;&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:nth-child(2){color:var(--color-base-100);rotate:0deg}&:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}}.toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.toggle:has(:checked)>:nth-child(3){opacity:1;rotate:0deg}.toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);background-color:currentColor;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@media (forced-colors:active){.toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.toggle:before{outline-offset:-1rem;outline:.25rem solid}}.toggle:focus-visible,.toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr;&:before{background-color:currentColor}@starting-style{&:before{opacity:0}}}.toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.toggle:disabled{cursor:not-allowed;opacity:.3;&:before{border:var(--border) solid currentColor;background-color:#0000}}.avatar-group{display:flex;overflow:hidden}.avatar-group .avatar{border:4px solid var(--color-base-100);border-radius:3.40282e38px;overflow:hidden}.avatar{vertical-align:middle;align-self:center;display:inline-flex;position:relative}.avatar>div{aspect-ratio:1;display:block;overflow:hidden}.avatar img{object-fit:cover;width:100%;height:100%}.breadcrumbs{max-width:100%;margin-inline-start:-.25rem;padding-block:.5rem;overflow-x:auto}.breadcrumbs>menu,.breadcrumbs>ul,.breadcrumbs>ol{white-space:nowrap;align-items:center;min-height:min-content;padding-inline-start:.25rem;display:flex;&>li{align-items:center;display:flex;&>*{cursor:pointer;align-items:center;gap:.5rem;display:flex;&:hover{@media (hover:hover){text-decoration-line:underline}}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible{outline-offset:2px;outline:2px solid}}&+:before{content:"";opacity:.4;background-color:#0000;border-top:1px solid;border-right:1px solid;width:.375rem;height:.375rem;margin-inline:.5rem .75rem;display:block;rotate:45deg}[dir=rtl] &+:before{rotate:-135deg}}}.hover-gallery{--items:1;grid-template-columns:repeat(var(--items), 1fr);gap:1px;width:100%;overflow:hidden}.hover-gallery,.hover-gallery:is(figure){display:inline-grid}.hover-gallery:has(>:nth-child(3)){--items:2}.hover-gallery:has(>:nth-child(4)){--items:3}.hover-gallery:has(>:nth-child(5)){--items:4}.hover-gallery:has(>:nth-child(6)){--items:5}.hover-gallery:has(>:nth-child(7)){--items:6}.hover-gallery:has(>:nth-child(8)){--items:7}.hover-gallery:has(>:nth-child(9)){--items:8}.hover-gallery:has(>:nth-child(10)){--items:9}.hover-gallery>*{opacity:0;object-fit:cover;grid-row:1;width:100%;height:100%;&:first-child{opacity:1;grid-column:1/-1}&:nth-child(2){grid-column:1}&:nth-child(3){grid-column:2}&:nth-child(4){grid-column:3}&:nth-child(5){grid-column:4}&:nth-child(6){grid-column:5}&:nth-child(7){grid-column:6}&:nth-child(8){grid-column:7}&:nth-child(9){grid-column:8}&:nth-child(10){grid-column:9}&:nth-child(n+11){display:none}}.hover-gallery>:hover{opacity:1;grid-column:1/-1}.hover-gallery:has(:hover)>:first-child{display:none}.textarea{appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}.textarea textarea{appearance:none;background-color:#0000;border:none;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}}.textarea:focus,.textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.textarea:focus,.textarea:focus-within{--font-size:1rem}}}.timeline{display:flex;position:relative}.timeline>li{grid-template-rows:var(--timeline-row-start,minmax(0, 1fr)) auto var(--timeline-row-end,minmax(0, 1fr));grid-template-columns:var(--timeline-col-start,minmax(0, 1fr)) auto var(--timeline-col-end,minmax(0, 1fr));flex-shrink:0;align-items:center;display:grid;position:relative;&>hr{border:none;width:100%;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}@media print{border:.1px solid var(--color-base-300)}}}.timeline :where(hr){background-color:var(--color-base-300);height:.25rem}.timeline:has(.timeline-middle hr):first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.timeline:has(.timeline-middle hr):last-child,.timeline:not(:has(.timeline-middle)) :first-child hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.timeline:not(:has(.timeline-middle)) :last-child hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.timeline-box{border:var(--border) solid;border-radius:var(--radius-box);border-color:var(--color-base-300);background-color:var(--color-base-100);padding-block:.5rem;padding-inline:1rem;font-size:.75rem;box-shadow:0 1px 2px oklch(0% 0 0/.05)}.timeline-start{grid-area:1/1/2/4;place-self:flex-end center;margin:.25rem}.timeline-middle{grid-row-start:2;grid-column-start:2}.timeline-end{grid-area:3/1/4/4;place-self:flex-start center;margin:.25rem}.stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.stat-figure{grid-row:1/span 3;grid-column-start:2;place-self:center flex-end}.stat-title{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.stat-value{white-space:nowrap;grid-column-start:1;font-size:2rem;font-weight:800}.stat-desc{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.stat-actions{white-space:nowrap;grid-column-start:1}:where(.btn){width:unset}.btn{--size:calc(var(--size-field,.25rem) * 10);--btn-p:1rem;--btn-fg:var(--color-base-content);cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;-webkit-user-select:none;user-select:none;border-width:var(--border);touch-action:manipulation;--btn-bg:var(--btn-color,var(--color-base-200));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 calc(var(--depth) * 5%));--btn-soft-bg:initial;--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));height:var(--size);padding-inline:var(--btn-p);font-weight:600;font-size:var(--fontsize,.875rem);background-color:var(--btn-bg);color:var(--btn-fg);border-color:var(--btn-border);border-style:var(--btn-border-style,solid);outline-color:var(--btn-color,var(--color-base-content));--tw-prose-links:var(--btn-fg);background-image:none, var(--fx-noise);background-size:auto, calc(var(--noise,0) * 100%);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));box-shadow:var(--btn-inset) inset, var(--btn-shadow);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;transition-property:color,background-color,border-color,box-shadow,transform;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}.btn:is([type=checkbox],[type=radio]){appearance:none;&[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}}.btn:where(:checked:not(.filter [type=radio].btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.btn-outline,.btn-dash{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:var(--btn-color,var(--color-base-content));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.btn-dash{--btn-border-style:dashed}.btn-ghost{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content,currentColor)));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.btn-soft{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--btn-soft-bg,var(--color-base-100)));color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--btn-soft-bg,var(--color-base-100)));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.link{cursor:pointer;text-decoration-line:underline}.link:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.link:focus-visible{outline-offset:2px;outline:2px solid}.dock{z-index:1;background-color:var(--color-base-100);color:currentColor;border-top:.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);width:100%;height:4rem;height:calc(4rem + env(safe-area-inset-bottom));padding:.5rem;padding-bottom:env(safe-area-inset-bottom);flex-direction:row;justify-content:space-around;align-items:center;display:flex;position:fixed;bottom:0;left:0;right:0}.dock>:not(:where(script,style,template)){cursor:pointer;border-radius:var(--radius-box);background-color:#0000;flex-direction:column;flex-shrink:1;flex-basis:100%;justify-content:center;align-items:center;gap:1px;max-width:8rem;height:100%;margin-bottom:.5rem;transition:opacity .2s ease-out;display:flex;position:relative;@media (hover:hover){&:hover{opacity:.8}}&[aria-disabled=true],&[disabled]{&,&:hover{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 10%, transparent);opacity:1}}& .dock-label{font-size:.6875rem}&:after{content:"";background-color:#0000;border-top:3px solid #0000;border-radius:3.40282e38px;width:1.5rem;height:.25rem;transition:background-color .1s ease-out,text-color .1s ease-out,width .1s ease-out;position:absolute;bottom:.2rem}}.radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000));box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}.radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.radio:focus-visible{outline:2px solid}.radio:checked,.radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor;@media (prefers-reduced-motion:no-preference){animation:.2s ease-out radio}&:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){&:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{&:before{outline-offset:-1rem;outline:.25rem solid}}}.progress{appearance:none;border-radius:var(--radius-box);background-color:color-mix(in oklab, currentcolor 20%, transparent);width:100%;height:.5rem;color:var(--color-base-content);position:relative;overflow:hidden}.progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;@media (prefers-reduced-motion:no-preference){animation:5s ease-in-out infinite progress}@supports ((-moz-appearance:none)){&::-moz-progress-bar{background-color:#0000;@media (prefers-reduced-motion:no-preference){background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}}}@supports ((-moz-appearance:none)){.progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}@supports ((-webkit-appearance:none)){.progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}.cally{font-size:.7rem}.cally::part(container){user-select:none;padding:.5rem 1rem}.cally ::part(th){block-size:auto;font-weight:400}.cally::part(header){direction:ltr}.cally ::part(head){opacity:.5;font-size:.7rem}.cally::part(button){border-radius:var(--radius-field);background:0 0;border:none;padding:.5rem}.cally::part(button):hover{background:var(--color-base-200)}.cally ::part(day){border-radius:var(--radius-field);font-size:.7rem}.cally ::part(day):hover{background:var(--color-base-200)}.cally ::part(button day today){background:var(--color-primary);color:var(--color-primary-content)}.cally ::part(button day today):hover{background:var(--color-primary)}.cally ::part(selected){color:var(--color-base-100);background:var(--color-base-content);border-radius:var(--radius-field)}.cally ::part(selected):hover{background:var(--color-base-content)}.cally ::part(range-inner){border-radius:0}.cally ::part(range-start){border-start-end-radius:0;border-end-end-radius:0}.cally ::part(range-end){border-start-start-radius:0;border-end-start-radius:0}.cally ::part(range-start range-end){border-radius:var(--radius-field)}.cally calendar-month{width:100%}.react-day-picker{user-select:none;background-color:var(--color-base-100);border-radius:var(--radius-box);border:var(--border) solid var(--color-base-200);font-size:.75rem;display:inline-block;position:relative;overflow:clip}.react-day-picker[dir=rtl] .rdp-nav .rdp-chevron{transform-origin:50%;transform:rotate(180deg)}.react-day-picker *{box-sizing:border-box}.react-day-picker .rdp-day{text-align:center;width:2.25rem;height:2.25rem}.react-day-picker .rdp-day_button{cursor:pointer;font:inherit;color:inherit;border-radius:var(--radius-field);background:0 0;border:2px solid #0000;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;margin:0;padding:0;display:flex;&:disabled{cursor:revert}&:hover{background-color:var(--color-base-200)}&:disabled:hover,&[aria-disabled=true]:hover{cursor:not-allowed;background-color:#0000}}.react-day-picker .rdp-caption_label{z-index:1;white-space:nowrap;border:0;align-items:center;display:inline-flex;position:relative}.react-day-picker .rdp-button_next{border-radius:var(--radius-field);&:hover{background-color:var(--color-base-200)}}.react-day-picker .rdp-button_previous{border-radius:var(--radius-field);&:hover{background-color:var(--color-base-200)}}.react-day-picker .rdp-button_next,.react-day-picker .rdp-button_previous{cursor:pointer;font:inherit;color:inherit;appearance:none;background:0 0;border:none;justify-content:center;align-items:center;width:2.25rem;height:2.25rem;margin:0;padding:0;display:inline-flex;position:relative;&:disabled,&[aria-disabled=true]{cursor:revert;opacity:.5}&:disabled:hover,&[aria-disabled=true]:hover{background-color:#0000}}.react-day-picker .rdp-chevron{fill:var(--color-base-content);width:1rem;height:1rem;display:inline-block}.react-day-picker .rdp-dropdowns{align-items:center;gap:.5rem;display:inline-flex;position:relative}.react-day-picker .rdp-dropdown{z-index:2;opacity:0;appearance:none;cursor:inherit;line-height:inherit;border:none;width:100%;margin:0;padding:0;position:absolute;inset-block:0;inset-inline-start:0;&:focus-visible{&~.rdp-caption_label{outline:5px auto highlight;outline:5px auto -webkit-focus-ring-color}}}.react-day-picker .rdp-dropdown_root{align-items:center;display:inline-flex;position:relative;&[data-disabled=true]{& .rdp-chevron{opacity:.5}}}.react-day-picker .rdp-month_caption{height:2.75rem;font-size:.75rem;font-weight:inherit;place-content:center;display:flex}.react-day-picker .rdp-months{flex-wrap:wrap;gap:2rem;max-width:fit-content;padding:.5rem;display:flex;position:relative}.react-day-picker .rdp-month_grid{border-collapse:collapse}.react-day-picker .rdp-nav{inset-block-start:0;inset-inline-end:0;justify-content:space-between;align-items:center;width:100%;height:2.75rem;padding-inline:.5rem;display:flex;position:absolute;top:.25rem}.react-day-picker .rdp-weekday{opacity:.6;text-align:center;padding:.5rem 0;font-size:smaller;font-weight:500}.react-day-picker .rdp-week_number{opacity:.6;text-align:center;border:none;border-radius:100%;width:2.25rem;height:2.25rem;font-size:small;font-weight:400}.react-day-picker .rdp-today:not(.rdp-outside) .rdp-day_button{background:var(--color-primary);color:var(--color-primary-content)}.react-day-picker .rdp-selected{font-weight:inherit;font-size:.75rem;& .rdp-day_button{color:var(--color-base-100);background-color:var(--color-base-content);border-radius:var(--radius-field);border:none;&:hover{background-color:var(--color-base-content)}}}.react-day-picker .rdp-outside{opacity:.75}.react-day-picker .rdp-disabled{opacity:.5}.react-day-picker .rdp-hidden{visibility:hidden;color:var(--color-base-content)}.react-day-picker .rdp-range_start .rdp-day_button{border-radius:var(--radius-field) 0 0 var(--radius-field);background-color:var(--color-base-content);color:var(--color-base-100)}.react-day-picker .rdp-range_middle{background-color:var(--color-base-200)}.react-day-picker .rdp-range_middle .rdp-day_button{border:unset;border-radius:unset;color:inherit;background-color:#0000;&:hover{background-color:#0000}}.react-day-picker .rdp-range_end{color:var(--color-base-content);& .rdp-day_button{border-radius:0 var(--radius-field) var(--radius-field) 0}}.react-day-picker .rdp-range_end .rdp-day_button{background-color:var(--color-base-content);color:var(--color-base-100)}.react-day-picker .rdp-range_start.rdp-range_end{background:revert}.react-day-picker .rdp-focusable{cursor:pointer}.react-day-picker .rdp-footer{border-top:var(--border) solid var(--color-base-200);padding:.5rem}.pika-single:is(div){user-select:none;z-index:999;color:var(--color-base-content);background-color:var(--color-base-100);border-radius:var(--radius-box);border:var(--border) solid var(--color-base-200);padding:.5rem;font-size:.75rem;display:inline-block;position:relative;&:before,&:after{content:"";display:table}&:after{clear:both}&.is-hidden{display:none}&.is-bound{position:absolute}& .pika-lendar{float:left}& .pika-title{text-align:center;position:relative}& .pika-label{z-index:999;background-color:var(--color-base-100);margin:0;padding:5px 3px;display:inline-block;position:relative;overflow:hidden}& .pika-title{& select{cursor:pointer;z-index:999;opacity:0;margin:0;position:absolute;top:5px;left:0}}& .pika-prev,& .pika-next{cursor:pointer;color:#0000;border-radius:var(--radius-field);border:0;outline:none;width:2.25rem;height:2.25rem;font-size:1.2em;display:block;position:absolute;top:0;&:hover{background-color:var(--color-base-200)}&.is-disabled{cursor:default;opacity:.2}&:before{width:2.25rem;height:2.25rem;color:var(--color-base-content);line-height:2.25;display:inline-block}}& .pika-prev{left:0;&:before{--tw-content:"‹";content:var(--tw-content)}}& .pika-next{right:0;&:before{--tw-content:"›";content:var(--tw-content)}}& .pika-select{display:inline-block}& .pika-table{border-collapse:collapse;border-spacing:0;border:0;width:100%}& .pika-table{& th,& td{padding:0}& th{opacity:.6;text-align:center;width:2.25rem;height:2.25rem}}& .pika-button{cursor:pointer;text-align:right;text-align:center;border:0;outline:none;width:2.25rem;height:2.25rem;margin:0;padding:5px;display:block}& .pika-week{color:var(--color-base-content)}& .is-today{& .pika-button{background:var(--color-primary);color:var(--color-primary-content)}}& .is-selected,& .has-event{& .pika-button{&,&:hover{color:var(--color-base-100);background-color:var(--color-base-content);border-radius:var(--radius-field)}}}& .has-event{& .pika-button{background:var(--color-primary)}}& .is-disabled,& .is-inrange{& .pika-button{background:var(--color-base-200)}}& .is-startrange{& .pika-button{color:var(--color-base-100);background:var(--color-base-content);border-radius:var(--radius-field)}}& .is-endrange{& .pika-button{color:var(--color-base-100);background:var(--color-base-content);border-radius:var(--radius-field)}}& .is-disabled{& .pika-button{pointer-events:none;cursor:default;color:var(--color-base-content);opacity:.3}}& .is-outside-current-month{& .pika-button{color:var(--color-base-content);opacity:.3}}& .is-selection-disabled{pointer-events:none;cursor:default}& .pika-button:hover,& .pika-row.pick-whole-week:hover .pika-button{color:var(--color-base-content);background-color:var(--color-base-200);border-radius:var(--radius-field)}& .pika-table abbr{font-weight:400;text-decoration:none}}.vc{box-sizing:border-box;border-radius:var(--radius-box);border:var(--border) solid var(--color-base-200);background-color:var(--color-base-100);min-width:286px;color:var(--color-base-content);opacity:1;user-select:none;flex-direction:column;padding:1rem;transition-property:opacity;transition-duration:.2s;display:inline-flex;position:relative}.vc:focus-visible,.vc button:focus-visible,.vc [tabindex="0"]:focus-visible{border-radius:var(--radius-field);outline:1px solid var(--color-primary);outline-offset:-1px}.vc[data-vc-calendar-hidden]{pointer-events:none;opacity:0;& *{pointer-events:none!important}}.vc[data-vc-input]{box-shadow:0 9px 20px color-mix(in oklab, var(--color-base-content) 10%, transparent);position:absolute;&[data-vc-position=bottom]{margin-top:.25rem}&[data-vc-position=top]{margin-top:-.25rem}}.vc [data-vc=controls]{pointer-events:none;z-index:20;box-sizing:content-box;justify-content:space-between;align-items:center;padding:1.25rem 1rem 0;display:flex;position:absolute;inset-block-start:0;inset-inline:0}.vc [data-vc-arrow],.vc .vc-arrow{pointer-events:auto;cursor:pointer;border-radius:var(--radius-field);width:1.5rem;height:1.5rem;color:var(--color-base-content);background-color:#0000;border:0;display:block;position:relative;&:hover{background-color:var(--color-base-200)}&:hover:before{opacity:.6}&:before{content:"";background-position:50%;background-repeat:no-repeat;border:2px solid;border-width:0 2px 2px 0;width:.5rem;height:.5rem;position:absolute;inset-block-start:50%;inset-inline-start:50%}&[data-vc-arrow=prev]:before{transform:translate(-35%,-50%)rotate(135deg)}&[data-vc-arrow=next]:before{transform:translate(-65%,-50%)rotate(-45deg)}}.vc [data-vc=grid]{flex-wrap:wrap;flex-grow:1;gap:1.75rem;display:flex;&[data-vc-grid=hidden]{& [data-vc=column]{pointer-events:none;opacity:.3}& [data-vc=column][data-vc-column=month],& [data-vc=column][data-vc-column=year]{pointer-events:auto;opacity:1}}}.vc [data-vc=column]{flex-direction:column;flex-grow:1;min-width:240px;display:flex}.vc [data-vc=header]{align-items:center;margin-bottom:.75rem;display:flex;position:relative}.vc [data-vc-header=content],.vc .vc-header__content{color:var(--color-base-content)}.vc [data-vc-header=content]{white-space:pre-wrap;flex-grow:1;grid-auto-columns:max-content;grid-auto-flow:column;justify-content:center;align-items:center;padding-inline:1rem;display:grid}.vc [data-vc=month],.vc [data-vc=year],.vc .vc-month,.vc .vc-year{color:var(--color-base-content);&:hover{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}&:disabled{color:color-mix(in oklab, var(--color-base-content) 30%, transparent)}}.vc [data-vc=month],.vc [data-vc=year]{cursor:pointer;border-radius:var(--radius-field);background-color:#0000;border:0;padding:.25rem;font-size:.875rem;font-weight:600;&:hover{background-color:var(--color-base-200)}&:disabled{pointer-events:none;opacity:.4}}.vc [data-vc=wrapper],.vc [data-vc=content]{flex-grow:1;display:flex}.vc [data-vc=content]{flex-direction:column}.vc [data-vc=months],.vc [data-vc=years]{flex-grow:1;align-items:center;gap:1rem .25rem;display:grid}.vc [data-vc=months]{grid-template-columns:repeat(4,minmax(0,1fr))}.vc [data-vc=years]{grid-template-columns:repeat(5,minmax(0,1fr))}.vc [data-vc-months-month],.vc [data-vc-years-year],.vc .vc-months__month,.vc .vc-years__year{background-color:var(--color-base-100);color:color-mix(in oklab, var(--color-base-content) 70%, transparent);&:hover{background-color:var(--color-base-200);color:var(--color-base-content)}&:disabled{color:color-mix(in oklab, var(--color-base-content) 30%, transparent);opacity:.8;&:hover{color:color-mix(in oklab, var(--color-base-content) 30%, transparent)}}&[data-vc-months-month-selected],&[data-vc-years-year-selected]{background-color:var(--color-primary);color:var(--color-primary-content);&:hover{background-color:var(--color-primary);color:var(--color-primary-content)}}}.vc [data-vc-months-month],.vc [data-vc-years-year]{cursor:pointer;overflow-wrap:anywhere;border-radius:var(--radius-field);text-align:center;border:0;justify-content:center;align-items:center;height:2.5rem;padding:.25rem;font-size:.75rem;font-weight:600;display:flex;&:disabled{pointer-events:none;opacity:.4}}.vc [data-vc-week=numbers]{flex-direction:column;display:flex}.vc [data-vc-week-numbers=title],.vc .vc-week-numbers__title{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}.vc [data-vc-week-numbers=title]{justify-content:center;align-items:center;margin-bottom:.5rem;font-size:.75rem;font-weight:700;display:flex}.vc [data-vc-week-numbers=content]{grid-auto-flow:row;place-items:center;row-gap:.25rem;display:grid}.vc [data-vc-week-number],.vc .vc-week-number{color:color-mix(in oklab, var(--color-base-content) 60%, transparent);&:hover{color:color-mix(in oklab, var(--color-base-content) 80%, transparent)}}.vc [data-vc-week-number]{cursor:pointer;background-color:#0000;border:0;justify-content:center;align-items:center;width:100%;min-width:1.875rem;min-height:1.875rem;margin:0;padding:0;font-size:.75rem;font-weight:600;display:flex}.vc [data-vc=week]{grid-template-columns:repeat(7,1fr);justify-items:center;margin-bottom:.5rem;display:grid}.vc [data-vc-week-day],.vc .vc-week__day{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}.vc [data-vc-week-day]{background-color:#0000;border:0;justify-content:center;align-items:center;width:100%;min-width:1.875rem;margin:0;padding:0;font-size:.75rem;font-weight:400;display:flex}.vc button[data-vc-week-day],.vc button.vc-week__day{cursor:pointer;&:hover{color:var(--color-base-content)}}.vc [data-vc=dates]{pointer-events:none;flex-grow:1;grid-template-rows:auto;grid-template-columns:1fr;place-items:center;display:grid;&[data-vc-dates-disabled] [data-vc-date-btn]{cursor:default}}.vc[data-vc-type=multiple] [data-vc=dates]{flex-grow:0}.vc [data-vc-dates=row]{grid-template-columns:repeat(7,1fr);place-items:center;width:100%;display:grid}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-month=next]) .vc-date__btn{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-disabled]{pointer-events:none;& [data-vc-date-btn],& .vc-date__btn{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 30%, transparent);opacity:.8}}:is(.vc [data-vc-date],.vc .vc-date):not(:has([data-vc-date-btn])){pointer-events:none}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover] [data-vc-date-btn]{border-radius:0}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover] .vc-date__btn{background-color:var(--color-base-200)}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-hover=first],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-hover=last]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-hover=first],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-hover=last]) .vc-date__btn{background-color:var(--color-base-300);&:hover{background-color:var(--color-base-300)}}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-hover=first] [data-vc-date-btn]{border-start-start-radius:var(--radius-field);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-field)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-hover=last] [data-vc-date-btn]{border-start-start-radius:0;border-start-end-radius:var(--radius-field);border-end-end-radius:var(--radius-field);border-end-start-radius:0}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-hover=first-and-last] [data-vc-date-btn]{border-radius:var(--radius-field)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-selected][data-vc-date-hover=first] [data-vc-date-btn]{border-start-start-radius:var(--radius-field);border-end-start-radius:var(--radius-field)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover][data-vc-date-selected][data-vc-date-hover=last] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-hover]:has(+[data-vc-date-disabled]) [data-vc-date-btn]{border-start-end-radius:var(--radius-field);border-end-end-radius:var(--radius-field)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected] .vc-date__btn{background-color:var(--color-base-content);color:var(--color-base-100);&:hover{background-color:var(--color-base-content);color:var(--color-base-100)}}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected]:has(+[data-vc-date-disabled]) [data-vc-date-btn]{border-start-end-radius:var(--radius-field);border-end-end-radius:var(--radius-field)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-selected=first] [data-vc-date-btn]{border-start-start-radius:var(--radius-field);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-field)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-selected=last] [data-vc-date-btn]{border-start-start-radius:0;border-start-end-radius:var(--radius-field);border-end-end-radius:var(--radius-field);border-end-start-radius:0}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-selected=first-and-last] [data-vc-date-btn]{border-start-start-radius:var(--radius-field);border-start-end-radius:var(--radius-field);border-end-end-radius:var(--radius-field);border-end-start-radius:var(--radius-field)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-selected=middle] [data-vc-date-btn]{border-radius:0}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-month=next]) .vc-date__btn{background-color:var(--color-base-300);color:color-mix(in oklab, var(--color-base-content) 60%, transparent);&:hover{background-color:var(--color-base-300);color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-selected=middle][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-selected=middle][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-selected=middle][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-selected][data-vc-date-selected=middle][data-vc-date-month=next]) .vc-date__btn{background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 60%, transparent);&:hover{background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-today] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-today] .vc-date__btn{background-color:var(--color-primary);color:var(--color-primary-content);font-weight:700;&:hover{color:var(--color-primary-content)}}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-today][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-today][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-today][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-today][data-vc-date-month=next]) .vc-date__btn{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday] .vc-date__btn{color:var(--color-error);&:hover{background-color:color-mix(in oklab, var(--color-error) 10%, transparent)}}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover] .vc-date__btn{background-color:color-mix(in oklab, var(--color-error) 10%, transparent)}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover][data-vc-date-hover=first],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover][data-vc-date-hover=last]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover][data-vc-date-hover=first],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover][data-vc-date-hover=last]) .vc-date__btn{background-color:color-mix(in oklab, var(--color-error) 20%, transparent);&:hover{background-color:color-mix(in oklab, var(--color-error) 20%, transparent)}}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-disabled] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-disabled] .vc-date__btn{color:color-mix(in oklab, var(--color-base-content) 30%, transparent);opacity:.8}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-today] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-today] .vc-date__btn{background-color:var(--color-error);color:var(--color-error-content)}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-today][data-vc-date-disabled] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-today][data-vc-date-disabled] .vc-date__btn{color:color-mix(in oklab, var(--color-base-content) 30%, transparent)}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-today][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-today][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-today][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-today][data-vc-date-month=next]) .vc-date__btn{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-month=next]) .vc-date__btn{background-color:var(--color-base-100);color:color-mix(in oklab, var(--color-base-content) 40%, transparent);&:hover{background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-hover][data-vc-date-month=next]) .vc-date__btn{background-color:var(--color-base-200)}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-disabled][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-disabled][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-disabled][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-disabled][data-vc-date-month=next]) .vc-date__btn{color:color-mix(in oklab, var(--color-base-content) 30%, transparent);opacity:.8}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected] .vc-date__btn{background-color:var(--color-error);color:var(--color-error-content);&:hover{background-color:var(--color-error);color:var(--color-error-content)}}:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-selected=middle] [data-vc-date-btn],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-selected=middle] .vc-date__btn{background-color:color-mix(in oklab, var(--color-error) 75%, transparent);color:var(--color-error-content);&:hover{background-color:color-mix(in oklab, var(--color-error) 75%, transparent);color:var(--color-error-content)}}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-month=next]) .vc-date__btn{background-color:var(--color-base-300);color:color-mix(in oklab, var(--color-base-content) 60%, transparent);&:hover{background-color:var(--color-base-300);color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-selected=middle][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-selected=middle][data-vc-date-month=next]) [data-vc-date-btn],:is(:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-selected=middle][data-vc-date-month=prev],:is(.vc [data-vc-date],.vc .vc-date)[data-vc-date-holiday][data-vc-date-selected][data-vc-date-selected=middle][data-vc-date-month=next]) .vc-date__btn{background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 60%, transparent);&:hover{background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.vc [data-vc-date]{pointer-events:auto;justify-content:center;align-items:center;width:100%;padding-block:.125rem;display:flex;position:relative;&[data-vc-date-disabled]+[data-vc-date-selected] [data-vc-date-btn],&[data-vc-date-disabled]+[data-vc-date-hover] [data-vc-date-btn]{border-start-start-radius:var(--radius-field);border-end-start-radius:var(--radius-field)}}.vc [data-vc-date-btn],.vc .vc-date__btn{background-color:var(--color-base-100);color:var(--color-base-content);&:hover{background-color:var(--color-base-200)}}.vc [data-vc-date-btn]{cursor:pointer;border-radius:var(--radius-field);border:0;justify-content:center;align-items:center;width:100%;min-width:1.875rem;height:100%;min-height:1.875rem;padding:0;font-size:.75rem;font-weight:400;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-duration:75ms;display:flex}.vc [data-vc-date-btn]:focus-visible+[data-vc-date-popup],.vc [data-vc-date-btn]:hover+[data-vc-date-popup],.vc [data-vc-date-popup]:focus-visible,.vc [data-vc-date-popup]:hover{pointer-events:auto;opacity:1}.vc [data-vc-date-popup],.vc .vc-date__popup{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:0 3px 15px color-mix(in oklab, var(--color-base-content) 20%, transparent)}.vc [data-vc-date-popup]{pointer-events:none;z-index:20;border-radius:var(--radius-field);opacity:0;min-width:5rem;max-width:9rem;padding:.25rem .5rem;font-size:.75rem;font-weight:400;transition-property:opacity;transition-duration:75ms;position:absolute;transform:translate(-50%);&:hover{pointer-events:auto;opacity:1}}.vc [data-vc-date-range-tooltip],.vc .vc-date-range-tooltip{background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 70%, transparent);box-shadow:0 1px 4px color-mix(in oklab, var(--color-base-content) 20%, transparent)}.vc [data-vc-date-range-tooltip]{pointer-events:none;z-index:30;border-radius:var(--radius-field);max-width:9rem;padding:.25rem .5rem;font-size:.75rem;font-weight:400;position:absolute;transform:translate(-50%,-100%);&[data-vc-date-range-tooltip=hidden]{opacity:0}&[data-vc-date-range-tooltip=visible]{opacity:1}}.vc [data-vc=time],.vc .vc-time{border-color:var(--color-base-200)}.vc [data-vc=time]{border-style:solid;border-width:var(--border) 0 0;grid-template-columns:auto 1fr;gap:.75rem;margin-top:.75rem;padding-top:.75rem;display:grid}.vc [data-vc-time=content]{grid-auto-flow:column;align-items:center;display:grid}:is(.vc [data-vc-time-input=hour],.vc [data-vc-time-input=minute],.vc .vc-time__hour,.vc .vc-time__minute):after{color:var(--color-base-content)}.vc [data-vc-time-input=hour]{width:1.75rem;margin-right:.35rem;position:relative;&:after{--tw-content:":";content:var(--tw-content);margin-top:calc(1px - 50%);display:block;position:absolute;inset-block-start:50%;inset-inline-end:-5px}}.vc [data-vc-time-input=minute]{width:1.75rem}.vc [data-vc-time-input=hour] input,.vc [data-vc-time-input=minute] input,.vc .vc-time__hour input,.vc .vc-time__minute input{background-color:var(--color-base-100);color:var(--color-base-content);&:hover,&[data-vc-input-focus]{background-color:var(--color-base-200)}&:focus-visible{outline-color:var(--color-primary)}&[data-vc-input-focus]{background-color:var(--color-base-200)}}.vc [data-vc-time-input=hour] input,.vc [data-vc-time-input=minute] input{box-sizing:border-box;border-radius:var(--radius-field);text-align:center;border:0;width:100%;margin:0;padding:.125rem;font-size:1.125rem;font-weight:600;line-height:1.125rem;display:block;position:relative;&:disabled{cursor:default;&:hover{background-color:#0000}}&:focus-visible{outline:1px solid var(--color-primary)}}.vc [data-vc-time=keeping],.vc .vc-time__keeping{color:color-mix(in oklab, var(--color-base-content) 70%, transparent);&:hover{background-color:var(--color-base-200);color:var(--color-base-content)}&:focus-visible{outline-color:var(--color-primary)}}.vc [data-vc-time=keeping]{cursor:pointer;border-radius:var(--radius-field);background-color:#0000;border:0;width:22px;margin-top:.25rem;margin-left:1px;padding:0;font-size:.69rem;&:disabled{cursor:default;&:hover{background-color:#0000}}&:focus-visible{outline:1px solid var(--color-primary)}}.vc [data-vc-time=ranges]{grid-auto-flow:row;display:grid}:is(.vc [data-vc-time-range],.vc .vc-time__range) input{background-color:var(--color-base-100);&:focus-visible{&::-webkit-slider-thumb,&::-moz-range-thumb{border-color:var(--color-primary)}}&::-webkit-slider-thumb,&::-moz-range-thumb{border-color:var(--color-base-300);background-color:var(--color-base-100)}&::-webkit-slider-runnable-track,&::-moz-range-track{background-color:var(--color-base-300)}}:is(.vc [data-vc-time-range],.vc .vc-time__range):hover input::-webkit-slider-thumb,:is(.vc [data-vc-time-range],.vc .vc-time__range):hover input::-moz-range-thumb{border-color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}:is(.vc [data-vc-time-range],.vc .vc-time__range):before,:is(.vc [data-vc-time-range],.vc .vc-time__range):after{background-color:var(--color-base-300)}.vc [data-vc-time-range]{z-index:10;font-size:0;position:relative;&:before{inset-inline-start:0}&:after{inset-inline-end:0}&:before,&:after{content:"";pointer-events:none;z-index:10;width:1px;height:.5rem;position:absolute;inset-block-start:50%;transform:translateY(-50%)}& input{cursor:pointer;appearance:none;outline:0;width:100%;height:1.25rem;margin:0;position:relative;&::-webkit-slider-thumb{appearance:none;margin-top:-.5rem}&::-webkit-slider-thumb,&::-moz-range-thumb{z-index:20;box-sizing:border-box;cursor:pointer;border:var(--border) solid var(--color-base-300);border-radius:var(--radius-field);width:.75rem;height:1rem;box-shadow:none;position:relative}&::-webkit-slider-runnable-track,&::-moz-range-track{box-sizing:border-box;cursor:pointer;width:100%;height:1px;box-shadow:none;margin-top:1px}}}.skeleton{border-radius:var(--radius-box);background-color:var(--color-base-300)}@media (prefers-reduced-motion:reduce){.skeleton{transition-duration:15s}}.skeleton{will-change:background-position;background-image:linear-gradient(105deg, #0000 0% 40%, var(--color-base-100) 50%, #0000 60% 100%);background-position-x:-50%;background-size:200%}@media (prefers-reduced-motion:no-preference){.skeleton{animation:1.8s ease-in-out infinite skeleton}.skeleton:dir(rtl){animation-direction:reverse}}.radial-progress{height:var(--size);width:var(--size);vertical-align:middle;box-sizing:content-box;--value:0;--size:5rem;--thickness:calc(var(--size) / 10);--radialprogress:calc(var(--value) * 1%);background-color:#0000;border-radius:3.40282e38px;flex-shrink:0;place-content:center;transition:--radialprogress .3s linear;display:inline-grid;position:relative}.radial-progress:before{content:"";background:radial-gradient(farthest-side, currentColor 98%, #0000) top/var(--thickness) var(--thickness) no-repeat, conic-gradient(currentColor var(--radialprogress), #0000 0);-webkit-mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));border-radius:3.40282e38px;position:absolute;inset:0}.radial-progress:after{content:"";inset:calc(50% - var(--thickness) / 2);transform:rotate(calc(var(--value) * 3.6deg - 90deg)) translate(calc(var(--size) / 2 - 50%));background-color:currentColor;border-radius:3.40282e38px;transition:transform .3s linear;position:absolute}.checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));&:before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1;translate:3.5% -7%}@media (forced-colors:active){&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}}.checkbox:indeterminate,.checkbox[aria-checked=mixed]{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));&:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -40%;rotate:0deg}}.drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}:where(.drawer-side){overflow:hidden}.drawer-side{pointer-events:none;visibility:hidden;inset-inline-start:0;z-index:10;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete, visibility .3s ease-out .1s allow-discrete;background-color:#0000;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0}.drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.drawer-side>*{grid-row-start:1;grid-column-start:1}.drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out,width .2s ease-out;translate:-100%;[dir=rtl] &{translate:100%}}.drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4));&>:not(.drawer-overlay){will-change:auto;transform:none}}:where(:root:has(.drawer-toggle:checked)){--page-scroll-lock: }.drawer-open>.drawer-toggle:checked~.drawer-side{scrollbar-color:revert-layer}:root:has(.drawer-open>.drawer-toggle:checked){--page-scroll-lock:revert-layer}.modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:overlay .3s allow-discrete, visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.modal::backdrop{display:none}.modal[popover]{color:inherit;background:0 0;border:0;max-width:none;max-height:none;margin:0;padding:0;inset:0;&::backdrop{background-color:oklch(0% 0 0/.4);transition:background-color .3s ease-out}}.modal-action{justify-content:flex-end;gap:.5rem;margin-top:1.5rem;display:flex}.modal-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}.modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.modal-backdrop button{cursor:pointer}.modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:.95;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}.validator-hint{visibility:hidden;margin-top:.5rem;font-size:.75rem}.footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.footer>:not(script,style,template){place-items:start;gap:.5rem;display:grid}.footer-title{text-transform:uppercase;opacity:.6;margin-bottom:.5rem;font-weight:600}.menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.menu :where(li ul,li menu){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;&:before{inset-inline-start:0;background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";position:absolute;top:.75rem;bottom:.75rem}}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.menu :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.menu :where(li>details>summary){--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}&::-webkit-details-marker{display:none}}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.menu details{interpolate-size:allow-keywords;overflow:hidden}.menu details::details-content{block-size:0;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.menu details[open]::details-content{block-size:auto}.menu :where(li>details[open]>summary):after,.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);color:var(--color-base-content);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover){cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative;& .badge{justify-self:flex-end}&>:not(ul,menu,.menu-title,details,.btn):active,&>:not(ul,menu,.menu-title,details,.btn).menu-active,&>:not(ul,menu,.menu-title,details,.btn)[aria-current]:not([aria-current=false],[aria-current=""]),&>details>summary:active{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);&:not(&:active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}}&.menu-disabled,& [disabled]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.menu .dropdown-content{margin-top:.5rem;padding:.5rem;&:before{display:none}}.menu-title{color:color-mix(in oklab, var(--color-base-content) 40%, transparent);padding-block:.5rem;padding-inline:.75rem;font-size:.875rem;font-weight:600}.chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.chat-bubble{border-radius:var(--radius-field);background-color:var(--color-base-300);width:fit-content;color:var(--color-base-content);grid-row-end:3;min-width:2.5rem;max-width:90%;min-height:2rem;padding-block:.5rem;padding-inline:1rem;display:block;position:relative}.chat-bubble:before{background-color:inherit;content:"";width:.75rem;height:.75rem;mask-repeat:no-repeat;mask-image:var(--mask-chat);position:absolute;bottom:0;mask-position:0 -1px;mask-size:.8125rem}.chat-image{grid-row:span 2/span 2;align-self:flex-end}.chat-header{grid-row-start:1;gap:.25rem;font-size:.6875rem;display:flex}.chat-footer{grid-row-start:3;gap:.25rem;font-size:.6875rem;display:flex}}@layer daisyui.l1.l2{.tooltip-top>.tooltip-content,.tooltip-top[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip-top:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.tooltip-bottom>.tooltip-content,.tooltip-bottom[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem));inset:var(--tt-off) auto auto 50%}.tooltip-bottom:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem)) rotate(180deg);inset:var(--tt-tail) auto auto 50%}.tooltip-start{--tt-trans:0;--tt-inset:0 auto;--tt-tail-inset:var(--tt-tail-off) auto}.tooltip-start>.tooltip-content,.tooltip-start[data-tip]:before{left:auto;right:auto;inset-inline:0 auto}.tooltip-start:after{left:auto;right:auto;inset-inline:var(--tt-tail-off) auto}.tooltip-center{--tt-trans:-50%;--tt-inset:50% auto;--tt-tail-inset:50% auto}.tooltip-center>.tooltip-content,.tooltip-center[data-tip]:before,.tooltip-center:after{inset-inline:auto;left:50%;right:auto}.tooltip-end{--tt-trans:0;--tt-inset:auto 0;--tt-tail-inset:auto var(--tt-tail-off)}.tooltip-end>.tooltip-content,.tooltip-end[data-tip]:before{left:auto;right:auto;inset-inline:auto 0}.tooltip-end:after{left:auto;right:auto;inset-inline:auto var(--tt-tail-off)}.tooltip-left{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.tooltip-left>.tooltip-content,.tooltip-left[data-tip]:before{transform:translateX(calc(var(--tt-pos,.25rem) - .25rem)) translateY(var(--tt-trans,-50%));left:auto;right:var(--tt-off);inset-block:var(--tt-inset,50% auto)}.tooltip-left:after{transform:translateX(var(--tt-pos,.25rem)) translateY(var(--tt-trans,-50%)) rotate(-90deg);left:auto;right:calc(var(--tt-tail) + 1px);inset-block:var(--tt-tail-inset,50% auto)}.tooltip-right{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.tooltip-right>.tooltip-content,.tooltip-right[data-tip]:before{transform:translateX(calc(var(--tt-pos,-.25rem) + .25rem)) translateY(var(--tt-trans,-50%));left:var(--tt-off);right:auto;inset-block:var(--tt-inset,50% auto)}.tooltip-right:after{transform:translateX(var(--tt-pos,-.25rem)) translateY(var(--tt-trans,-50%)) rotate(90deg);left:calc(var(--tt-tail) + 1px);right:auto;inset-block:var(--tt-tail-inset,50% auto)}.tooltip-primary{--tt-bg:var(--color-primary)}.tooltip-primary>.tooltip-content,.tooltip-primary[data-tip]:before{color:var(--color-primary-content)}.tooltip-secondary{--tt-bg:var(--color-secondary)}.tooltip-secondary>.tooltip-content,.tooltip-secondary[data-tip]:before{color:var(--color-secondary-content)}.tooltip-accent{--tt-bg:var(--color-accent)}.tooltip-accent>.tooltip-content,.tooltip-accent[data-tip]:before{color:var(--color-accent-content)}.tooltip-info{--tt-bg:var(--color-info)}.tooltip-info>.tooltip-content,.tooltip-info[data-tip]:before{color:var(--color-info-content)}.tooltip-success{--tt-bg:var(--color-success)}.tooltip-success>.tooltip-content,.tooltip-success[data-tip]:before{color:var(--color-success-content)}.tooltip-warning{--tt-bg:var(--color-warning)}.tooltip-warning>.tooltip-content,.tooltip-warning[data-tip]:before{color:var(--color-warning-content)}.tooltip-error{--tt-bg:var(--color-error)}.tooltip-error>.tooltip-content,.tooltip-error[data-tip]:before{color:var(--color-error-content)}.table-zebra tbody tr:where(:nth-child(2n)){background-color:var(--color-base-200);& :where(.table-pin-cols tr th){background-color:var(--color-base-200)}}@media (hover:hover){:is(:is(.table-zebra tbody tr.row-hover),.table-zebra tbody tr.row-hover:where(:nth-child(2n))):hover{background-color:var(--color-base-300)}}.table-xs :not(thead,tfoot) tr{font-size:.6875rem}.table-xs :where(th,td){padding-block:.25rem;padding-inline:.5rem}.table-sm :not(thead,tfoot) tr{font-size:.75rem}.table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.table-md :not(thead,tfoot) tr{font-size:.875rem}.table-md :where(th,td){padding-block:.75rem;padding-inline:1rem}.table-lg :not(thead,tfoot) tr{font-size:1.125rem}.table-lg :where(th,td){padding-block:1rem;padding-inline:1.25rem}.table-xl :not(thead,tfoot) tr{font-size:1.375rem}.table-xl :where(th,td){padding-block:1.25rem;padding-inline:1.5rem}.file-input:has(>input[disabled]),.file-input:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}box-shadow:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);&::file-selector-button{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);--btn-border:#0000;--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000);background-image:none}}.file-input-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.file-input-ghost::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;border-start-end-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-end-radius:calc(var(--join-es,var(--radius-field) - var(--border)));height:100%;margin-block:0;margin-inline:0 1rem;padding-inline:1rem}.file-input-ghost:focus,.file-input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.file-input-neutral{--btn-color:var(--color-neutral)}.file-input-neutral::file-selector-button{color:var(--color-neutral-content)}.file-input-neutral,.file-input-neutral:focus,.file-input-neutral:focus-within{--input-color:var(--color-neutral)}.file-input-primary{--btn-color:var(--color-primary)}.file-input-primary::file-selector-button{color:var(--color-primary-content)}.file-input-primary,.file-input-primary:focus,.file-input-primary:focus-within{--input-color:var(--color-primary)}.file-input-secondary{--btn-color:var(--color-secondary)}.file-input-secondary::file-selector-button{color:var(--color-secondary-content)}.file-input-secondary,.file-input-secondary:focus,.file-input-secondary:focus-within{--input-color:var(--color-secondary)}.file-input-accent{--btn-color:var(--color-accent)}.file-input-accent::file-selector-button{color:var(--color-accent-content)}.file-input-accent,.file-input-accent:focus,.file-input-accent:focus-within{--input-color:var(--color-accent)}.file-input-info{--btn-color:var(--color-info)}.file-input-info::file-selector-button{color:var(--color-info-content)}.file-input-info,.file-input-info:focus,.file-input-info:focus-within{--input-color:var(--color-info)}.file-input-success{--btn-color:var(--color-success)}.file-input-success::file-selector-button{color:var(--color-success-content)}.file-input-success,.file-input-success:focus,.file-input-success:focus-within{--input-color:var(--color-success)}.file-input-warning{--btn-color:var(--color-warning)}.file-input-warning::file-selector-button{color:var(--color-warning-content)}.file-input-warning,.file-input-warning:focus,.file-input-warning:focus-within{--input-color:var(--color-warning)}.file-input-error{--btn-color:var(--color-error)}.file-input-error::file-selector-button{color:var(--color-error-content)}.file-input-error,.file-input-error:focus,.file-input-error:focus-within{--input-color:var(--color-error)}.file-input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem;line-height:1rem}.file-input-xs::file-selector-button{font-size:.6875rem}.file-input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem;line-height:1.5rem}.file-input-sm::file-selector-button{font-size:.75rem}.file-input-md{--size:calc(var(--size-field,.25rem) * 10);font-size:.875rem;line-height:2}.file-input-md::file-selector-button{font-size:.875rem}.file-input-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:1.125rem;line-height:2.5rem}.file-input-lg::file-selector-button{font-size:1.125rem}.file-input-xl{--size:calc(var(--size-field,.25rem) * 14);padding-inline-end:1.5rem;font-size:1.125rem;line-height:3rem}.file-input-xl::file-selector-button{font-size:1.375rem}.carousel-vertical{scroll-snap-type:y mandatory;flex-direction:column;overflow-y:scroll}.carousel-horizontal{scroll-snap-type:x mandatory;flex-direction:row;overflow-x:scroll}.carousel-start .carousel-item{scroll-snap-align:start}.carousel-center .carousel-item{scroll-snap-align:center}.carousel-end .carousel-item{scroll-snap-align:end}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after,.steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.steps .step-primary+.step-primary:before,.steps .step-primary:after,.steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after,.steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.steps .step-accent+.step-accent:before,.steps .step-accent:after,.steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.steps .step-info+.step-info:before,.steps .step-info:after,.steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.steps .step-success+.step-success:before,.steps .step-success:after,.steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.steps .step-warning+.step-warning:before,.steps .step-warning:after,.steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.steps .step-error+.step-error:before,.steps .step-error:after,.steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.steps-horizontal{grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.steps-horizontal .step{text-align:center;grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;margin-inline-start:-100%;translate:0}[dir=rtl] &:before{translate:0}}.steps-vertical{grid-auto-rows:1fr;grid-auto-flow:row}.steps-vertical .step{grid-template-rows:auto;grid-template-columns:40px 1fr;justify-items:start;gap:.5rem;min-height:4rem;display:grid;&:before{width:.5rem;height:100%;margin-inline-start:50%;translate:-50% -50%}[dir=rtl] &:before{translate:50% -50%}}.dropdown-start{--anchor-h:span-inline-end}.dropdown-start :where(.dropdown-content){inset-inline-end:auto;translate:0;[dir=rtl] &{translate:0}}.dropdown-start.dropdown-left{--anchor-h:left;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.dropdown-start.dropdown-right{--anchor-h:right;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.dropdown-center{--anchor-h:center}.dropdown-center :where(.dropdown-content){inset-inline-end:50%;translate:50%;[dir=rtl] &{translate:-50%}}.dropdown-center.dropdown-left{--anchor-h:left;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.dropdown-center.dropdown-right{--anchor-h:right;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.dropdown-end{--anchor-h:span-inline-start}.dropdown-end :where(.dropdown-content){inset-inline-end:0;translate:0;[dir=rtl] &{translate:0}}.dropdown-end.dropdown-left{--anchor-h:left;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.dropdown-end.dropdown-right{--anchor-h:right;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.dropdown-left{--anchor-h:left;--anchor-v:span-bottom}.dropdown-left .dropdown-content{inset-inline-end:100%;transform-origin:100%;top:0;bottom:auto}.dropdown-right{--anchor-h:right;--anchor-v:span-bottom}.dropdown-right .dropdown-content{inset-inline-start:100%;transform-origin:0;top:0;bottom:auto}.dropdown-bottom{--anchor-v:block-end}.dropdown-bottom .dropdown-content{transform-origin:top;top:100%;bottom:auto}.dropdown-top{--anchor-v:block-start}.dropdown-top .dropdown-content{transform-origin:bottom;top:auto;bottom:100%}:where(.navbar){position:relative}.toast-start{--toast-x:0;inset-inline:1rem auto}.toast-center{--toast-x:-50%;inset-inline:50%}.toast-center:dir(rtl){--toast-x:50%}.toast-end{--toast-x:0;inset-inline:auto 1rem}.toast-bottom{--toast-y:0;top:auto;bottom:1rem}.toast-middle{--toast-y:-50%;top:50%;bottom:auto}.toast-top{--toast-y:0;top:1rem;bottom:auto}.range-primary{color:var(--color-primary);--range-thumb:var(--color-primary-content)}.range-secondary{color:var(--color-secondary);--range-thumb:var(--color-secondary-content)}.range-accent{color:var(--color-accent);--range-thumb:var(--color-accent-content)}.range-neutral{color:var(--color-neutral);--range-thumb:var(--color-neutral-content)}.range-success{color:var(--color-success);--range-thumb:var(--color-success-content)}.range-warning{color:var(--color-warning);--range-thumb:var(--color-warning-content)}.range-info{color:var(--color-info);--range-thumb:var(--color-info-content)}.range-error{color:var(--color-error);--range-thumb:var(--color-error-content)}.range-xs{--range-thumb-size:calc(var(--size-selector,.25rem) * 4)}.range-sm{--range-thumb-size:calc(var(--size-selector,.25rem) * 5)}.range-md{--range-thumb-size:calc(var(--size-selector,.25rem) * 6)}.range-lg{--range-thumb-size:calc(var(--size-selector,.25rem) * 7)}.range-xl{--range-thumb-size:calc(var(--size-selector,.25rem) * 8)}.range-vertical{writing-mode:vertical-lr;width:var(--range-thumb-size);--range-fill-x:0;--range-fill-y:calc(100cqh + var(--range-thumb-size) / 2);--range-fill-spread:calc(100cqh * var(--range-fill));direction:rtl;height:clamp(3rem,20rem,100%)}.range-vertical::-webkit-slider-runnable-track{height:100%;width:calc(var(--range-thumb-size) * .5)}.range-vertical::-webkit-slider-thumb{transform:translate(-50%)}.range-vertical::-moz-range-track{height:100%;width:calc(var(--range-thumb-size) * .5)}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(input),& :is(input){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.input-ghost:focus,.input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.input-neutral,.input-neutral:focus,.input-neutral:focus-within{--input-color:var(--color-neutral)}.input-primary,.input-primary:focus,.input-primary:focus-within{--input-color:var(--color-primary)}.input-secondary,.input-secondary:focus,.input-secondary:focus-within{--input-color:var(--color-secondary)}.input-accent,.input-accent:focus,.input-accent:focus-within{--input-color:var(--color-accent)}.input-info,.input-info:focus,.input-info:focus-within{--input-color:var(--color-info)}.input-success,.input-success:focus,.input-success:focus-within{--input-color:var(--color-success)}.input-warning,.input-warning:focus,.input-warning:focus-within{--input-color:var(--color-warning)}.input-error,.input-error:focus,.input-error:focus-within{--input-color:var(--color-error)}.input-xs{--in-size-mul:6;--font-size-min:.6875rem;--spin-my:-1}.floating-label:has(.input-xs){--top-mul:3;--font-size:.6875rem}.input-sm{--in-size-mul:8;--font-size-min:.75rem;--spin-my:-2}.floating-label:has(.input-sm){--top-mul:4;--font-size:.75rem}.input-md{--in-size-mul:10;--font-size-min:.875rem;--spin-my:-3}.floating-label:has(.input-md){--top-mul:5;--font-size:.875rem}.input-lg{--in-size-mul:12;--font-size-min:1.125rem;--spin-my:-3}.floating-label:has(.input-lg){--top-mul:6;--font-size:1.125rem}.input-xl{--in-size-mul:14;--font-size-min:1.375rem;--spin-my:-4}.floating-label:has(.input-xl){--top-mul:7;--font-size:1.375rem}.collapse-arrow>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;transform:translateY(-100%)rotate(45deg);@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:50%;content:"";transform-origin:75% 75%;pointer-events:none;inset-inline-end:1.4rem;box-shadow:2px 2px}.collapse-plus>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:.9rem;--tw-content:"+";content:var(--tw-content);pointer-events:none;inset-inline-end:1.4rem}.collapse-open{grid-template-rows:max-content 1fr}.collapse-open>.collapse-content{--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;padding-bottom:1rem;@supports not (content-visibility:visible){visibility:visible}}.list .list-row:has(>.list-col-grow:first-child){--list-grid-cols:1fr}.list .list-row:has(>.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.list .list-row:has(>.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(>.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(>.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(>.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row>*{grid-row-start:1}:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/4/6}}.stack.stack-top>*{grid-area:1/3/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:3/1/6/6}}.stack.stack-start>*{grid-area:3/1/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/3/6/6}}.stack.stack-end>*{grid-area:3/3/4/6;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/6/4}}.rating-half *{width:calc(var(--size) * .5)}.rating-xs{--size:var(--size-selector,.25rem) * 4}.rating-sm{--size:var(--size-selector,.25rem) * 5}.rating-md{--size:var(--size-selector,.25rem) * 6}.rating-lg{--size:var(--size-selector,.25rem) * 7}.rating-xl{--size:var(--size-selector,.25rem) * 8}.aura-rainbow{background:conic-gradient(from var(--aura-angle) in oklch longer hue, transparent 10%, oklch(80% .15 0), oklch(80% .15 360), transparent 90%)}.aura-holo{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(82% .17 327), oklch(75% .12 274), oklch(82% .11 191), oklch(91% .11 105), oklch(88% .08 68), oklch(82% .17 327) 10%);animation:aura var(--tw-duration,20s) linear infinite}@media (prefers-reduced-motion:reduce){.aura-holo{animation-duration:calc(var(--tw-duration,20s) * 4)}}.aura-dual{background-image:repeating-conic-gradient(from var(--aura-angle), transparent 0%, transparent 40%, currentColor 50%)}.aura-silver{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(30% 0 0), oklch(90% 0 0), oklch(60% 0 0), oklch(90% 0 0), oklch(50% 0 0), oklch(30% 0 0) 50%)}.aura-gold{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(65.98% .1863 72.37), oklch(96.35% .0768 102.94), oklch(71.57% .1691 82.23), oklch(96.02% .0792 103.13), oklch(60.66% .1181 76.17), oklch(65.98% .1863 72.37) 50%)}.aura-glow{background-image:radial-gradient(closest-corner,currentColor 0%,#0000 90%);animation:none}.aura-glow:before{animation:aura-glow var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.aura-glow:after{animation:aura-glow-after var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.aura-xs{--aura-padding:0rem}.aura-sm{--aura-padding:.0625rem}.aura-md{--aura-padding:.125rem}.aura-lg{--aura-padding:.15625rem}.aura-xl{--aura-padding:.25rem}.diff{-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;align-items:normal;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.diff:focus-visible,.diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content);& .diff-resizer{min-width:95cqi;max-width:95cqi}}.diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;& .diff-resizer{min-width:5cqi;max-width:5cqi}}.diff:hover .diff-item-2:after{height:2.4rem}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}}.card-border{border:var(--border) solid var(--color-base-200)}.card-dash{border:var(--border) dashed var(--color-base-200)}.image-full{display:grid}.image-full>*{grid-row-start:1;grid-column-start:1}.image-full>.card-body{color:var(--color-neutral-content);position:relative}.image-full :where(figure){border-radius:inherit;overflow:hidden}.image-full>figure img{object-fit:cover;filter:brightness(28%);height:100%}.card-xs .card-body{--card-p:.5rem;--card-fs:.6875rem}.card-xs .card-title{--cardtitle-fs:.875rem}.card-sm .card-body{--card-p:1rem;--card-fs:.75rem}.card-sm .card-title{--cardtitle-fs:1rem}.card-md .card-body{--card-p:1.5rem;--card-fs:.875rem}.card-md .card-title{--cardtitle-fs:1.125rem}.card-lg .card-body{--card-p:2rem;--card-fs:1rem}.card-lg .card-title{--cardtitle-fs:1.25rem}.card-xl .card-body{--card-p:2.5rem;--card-fs:1.125rem}.card-xl .card-title{--cardtitle-fs:1.375rem}.card-side{flex-direction:row;align-items:stretch}.card-side :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:unset;border-end-end-radius:unset;border-end-start-radius:inherit;overflow:hidden}.card-side :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:inherit;border-end-end-radius:inherit;border-end-start-radius:unset;overflow:hidden}.card-side figure>*{max-width:unset}.card-side :where(figure>*){object-fit:cover;width:100%;height:100%}.tab-disabled{pointer-events:none;opacity:.4}.tabs-border>.tab{--tab-border-color:#0000 #0000 var(--tab-border-color) #0000;border-radius:var(--radius-field);position:relative;&:before{content:"";background-color:var(--tab-border-color);width:calc(100% - var(--tab-p) * 2);border-radius:var(--radius-field);height:3px;bottom:0;left:var(--tab-p);transition:background-color .2s;position:absolute}&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){&:before{--tab-border-color:currentColor;border-top:3px solid}}}.tabs-lift{--tabs-height:auto;--tabs-direction:row}.tabs-lift>.tab{--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es);&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked,label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad));background-color:var(--tab-bg);&:before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start), var(--radius-end);background-size:var(--tab-radius-limit) var(--tab-radius-limit);inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}&:first-child:before{--radius-start:none}[dir=rtl] &:first-child:before{transform:rotateY(180deg)}&:last-child:before{--radius-end:none}[dir=rtl] &:last-child:before{transform:rotateY(180deg)}}}.tabs-lift:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.tabs-lift>.tab-content{--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.tabs-top{--tabs-height:auto;--tabs-direction:row}.tabs-top>.tab{--tab-order:0;--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad))}}.tabs-top:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.tabs-top>.tab-content{--tabcontent-order:1;--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.tabs-bottom{--tabs-height:auto;--tabs-direction:row}.tabs-bottom>.tab{--tab-order:1;--tab-border:var(--border) 0 0 0;--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:var(--tab-radius-limit);--tab-radius-ee:var(--tab-radius-limit);--tab-border-colors:var(--tab-border-color) #0000 #0000 #0000;--tab-paddings:0 var(--tab-p) var(--border) var(--tab-p);--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:0 var(--border) var(--border) var(--border);--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color);--tab-paddings:var(--border) calc(var(--tab-p) - var(--border)) 0 calc(var(--tab-p) - var(--border));--tab-inset:0 auto auto auto;--radius-start:radial-gradient(circle at bottom left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at bottom right, var(--tab-radius-grad))}}.tabs-bottom:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color)}.tabs-bottom>.tab-content{--tabcontent-order:0;--tabcontent-margin:0 0 calc(-1 * var(--border)) 0;--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:0;--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-bottom>:checked,.tabs-bottom>:is(label:has(:checked)),.tabs-bottom>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:not(:nth-child(2)){--tabcontent-radius-es:var(--radius-box)}.tabs-box{background-color:var(--color-base-200);--tabs-box-radius:calc(3 * var(--radius-field));border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)));box-shadow:0 -.5px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 .5px oklch(0% 0 0 / calc(var(--depth) * .05)) inset;padding:.25rem}.tabs-box>.tab{border-radius:var(--radius-field);border-style:none;&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:2px}&:focus-visible{z-index:1}}.tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.tabs-box>:is(input:checked),.tabs-box>:is(label:has(:checked)){background-color:var(--tab-bg,var(--color-base-100));box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);@media (forced-colors:active){border:1px solid}}.tabs-box>.tab-content{height:calc(100% - var(--tab-height) + var(--border) - .5rem);border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)) - var(--border));margin-top:.25rem}.tabs-xs{--tab-height:calc(var(--size-field,.25rem) * 6)}.tabs-xs>.tab{--tab-p:.375rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.75rem}.tabs-sm{--tab-height:calc(var(--size-field,.25rem) * 8)}.tabs-sm>.tab{--tab-p:.5rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.875rem}.tabs-md{--tab-height:calc(var(--size-field,.25rem) * 10)}.tabs-md>.tab{--tab-p:.75rem;--tab-radius-min:calc(.75rem - var(--border));font-size:.875rem}.tabs-lg{--tab-height:calc(var(--size-field,.25rem) * 12)}.tabs-lg>.tab{--tab-p:1rem;--tab-radius-min:calc(1.5rem - var(--border));font-size:1.125rem}.tabs-xl{--tab-height:calc(var(--size-field,.25rem) * 14)}.tabs-xl>.tab{--tab-p:1.25rem;--tab-radius-min:calc(2rem - var(--border));font-size:1.125rem}.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(select),& :is(select){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed}.select-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.select-ghost:focus,.select-ghost:focus-within,.select-ghost:open{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.select-ghost::picker(select){background-color:var(--color-base-100);color:var(--color-base-content)}.select-neutral,.select-neutral:focus,.select-neutral:focus-within,.select-neutral:open{--input-color:var(--color-neutral)}.select-primary,.select-primary:focus,.select-primary:focus-within,.select-primary:open{--input-color:var(--color-primary)}.select-secondary,.select-secondary:focus,.select-secondary:focus-within,.select-secondary:open{--input-color:var(--color-secondary)}.select-accent,.select-accent:focus,.select-accent:focus-within,.select-accent:open{--input-color:var(--color-accent)}.select-info,.select-info:focus,.select-info:focus-within,.select-info:open{--input-color:var(--color-info)}.select-success,.select-success:focus,.select-success:focus-within,.select-success:open{--input-color:var(--color-success)}.select-warning,.select-warning:focus,.select-warning:focus-within,.select-warning:open{--input-color:var(--color-warning)}.select-error,.select-error:focus,.select-error:focus-within,.select-error:open{--input-color:var(--color-error)}.select-xs{--sl-size-mul:6;--font-size-min:.6875rem;--option-px:2}.floating-label:has(.select-xs){--top-mul:3;--font-size:.6875rem}.select-sm{--sl-size-mul:8;--font-size-min:.75rem;--option-px:2.5}.floating-label:has(.select-sm){--top-mul:4;--font-size:.75rem}.select-md{--sl-size-mul:10;--font-size-min:.875rem;--option-px:3}.floating-label:has(.select-md){--top-mul:5;--font-size:.875rem}.select-lg{--sl-size-mul:12;--font-size-min:1.125rem;--option-px:4}.floating-label:has(.select-lg){--top-mul:6;--font-size:1.125rem}.select-xl{--sl-size-mul:14;--font-size-min:1.375rem;--option-px:5}.floating-label:has(.select-xl){--top-mul:7;--font-size:1.375rem}.mask-half-1{mask-position:0;mask-size:200%}.mask-half-1:where(:dir(rtl),[dir=rtl],[dir=rtl] *){mask-position:100%}.mask-half-2{mask-position:100%;mask-size:200%}.mask-half-2:where(:dir(rtl),[dir=rtl],[dir=rtl] *){mask-position:0}.mask-squircle{mask-image:url("data:image/svg+xml,%3csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M100 0C20 0 0 20 0 100s20 100 100 100 100-20 100-100S180 0 100 0Z'/%3e%3c/svg%3e")}.mask-decagon{mask-image:url("data:image/svg+xml,%3csvg width='192' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m96 0 58.779 19.098 36.327 50v61.804l-36.327 50L96 200l-58.779-19.098-36.327-50V69.098l36.327-50z' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-diamond{mask-image:url("data:image/svg+xml,%3csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m100 0 100 100-100 100L0 100z' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-heart{mask-image:url("data:image/svg+xml,%3csvg width='200' height='185' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M100 184.606a15.384 15.384 0 0 1-8.653-2.678C53.565 156.28 37.205 138.695 28.182 127.7 8.952 104.264-.254 80.202.005 54.146.308 24.287 24.264 0 53.406 0c21.192 0 35.869 11.937 44.416 21.879a2.884 2.884 0 0 0 4.356 0C110.725 11.927 125.402 0 146.594 0c29.142 0 53.098 24.287 53.4 54.151.26 26.061-8.956 50.122-28.176 73.554-9.023 10.994-25.383 28.58-63.165 54.228a15.384 15.384 0 0 1-8.653 2.673Z' fill='black' fill-rule='nonzero'/%3e%3c/svg%3e")}.mask-hexagon{mask-image:url("data:image/svg+xml,%3csvg width='182' height='201' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M.3 65.486c0-9.196 6.687-20.063 14.211-25.078l61.86-35.946c8.36-5.016 20.899-5.016 29.258 0l61.86 35.946c8.36 5.015 14.211 15.882 14.211 25.078v71.055c0 9.196-6.687 20.063-14.211 25.079l-61.86 35.945c-8.36 4.18-20.899 4.18-29.258 0L14.51 161.62C6.151 157.44.3 145.737.3 136.54V65.486Z' fill='black' fill-rule='nonzero'/%3e%3c/svg%3e")}.mask-hexagon-2{mask-image:url("data:image/svg+xml,%3csvg width='200' height='182' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M64.786 181.4c-9.196 0-20.063-6.687-25.079-14.21L3.762 105.33c-5.016-8.36-5.016-20.9 0-29.259l35.945-61.86C44.723 5.851 55.59 0 64.786 0h71.055c9.196 0 20.063 6.688 25.079 14.211l35.945 61.86c4.18 8.36 4.18 20.899 0 29.258l-35.945 61.86c-4.18 8.36-15.883 14.211-25.079 14.211H64.786Z' fill='black' fill-rule='nonzero'/%3e%3c/svg%3e")}.mask-circle{mask-image:url("data:image/svg+xml,%3csvg width='200' height='200' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle fill='black' cx='100' cy='100' r='100' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-pentagon{mask-image:url("data:image/svg+xml,%3csvg width='192' height='181' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m96 0 95.106 69.098-36.327 111.804H37.22L.894 69.098z' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-star{mask-image:url("data:image/svg+xml,%3csvg width='192' height='180' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m96 137.263-58.779 42.024 22.163-68.389L.894 68.481l72.476-.243L96 0l22.63 68.238 72.476.243-58.49 42.417 22.163 68.389z' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-star-2{mask-image:url("data:image/svg+xml,%3csvg width='192' height='180' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m96 153.044-58.779 26.243 7.02-63.513L.894 68.481l63.117-13.01L96 0l31.989 55.472 63.117 13.01-43.347 47.292 7.02 63.513z' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-triangle{mask-image:url("data:image/svg+xml,%3csvg width='174' height='149' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m87 148.476-86.603.185L43.86 74.423 87 0l43.14 74.423 43.463 74.238z' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-triangle-2{mask-image:url("data:image/svg+xml,%3csvg width='174' height='150' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m87 .738 86.603-.184-43.463 74.238L87 149.214 43.86 74.792.397.554z' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-triangle-3{mask-image:url("data:image/svg+xml,%3csvg width='150' height='174' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='m149.369 87.107.185 86.603-74.239-43.463L.893 87.107l74.422-43.14L149.554.505z' fill-rule='evenodd'/%3e%3c/svg%3e")}.mask-triangle-4{mask-image:url("data:image/svg+xml,%3csvg width='150' height='174' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M.631 87.107.446.505l74.239 43.462 74.422 43.14-74.422 43.14L.446 173.71z' fill-rule='evenodd'/%3e%3c/svg%3e")}.badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}.badge-dash{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-style:dashed;border-color:currentColor}.badge-soft{color:var(--badge-color,var(--color-base-content));background-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 8%, var(--color-base-100));border-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 10%, var(--color-base-100));background-image:none}.badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.badge-secondary{--badge-color:var(--color-secondary);--badge-fg:var(--color-secondary-content)}.badge-accent{--badge-color:var(--color-accent);--badge-fg:var(--color-accent-content)}.badge-neutral{--badge-color:var(--color-neutral);--badge-fg:var(--color-neutral-content)}.badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.badge-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.badge-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.badge-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.badge-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}.kbd-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.kbd-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.kbd-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.kbd-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.kbd-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}.otp-xs{--otp-size:calc(var(--size-field,.25rem) * 6);font-size:1.25rem}.otp-sm{--otp-size:calc(var(--size-field,.25rem) * 8);font-size:1.5rem}.otp-md{--otp-size:calc(var(--size-field,.25rem) * 10);font-size:1.75rem}.otp-lg{--otp-size:calc(var(--size-field,.25rem) * 12);font-size:2rem}.otp-xl{--otp-size:calc(var(--size-field,.25rem) * 14);font-size:2.5rem}.otp-neutral,.otp-neutral:focus,.otp-neutral:focus-within{--input-color:var(--color-neutral)}.otp-primary,.otp-primary:focus,.otp-primary:focus-within{--input-color:var(--color-primary)}.otp-secondary,.otp-secondary:focus,.otp-secondary:focus-within{--input-color:var(--color-secondary)}.otp-accent,.otp-accent:focus,.otp-accent:focus-within{--input-color:var(--color-accent)}.otp-info,.otp-info:focus,.otp-info:focus-within{--input-color:var(--color-info)}.otp-success,.otp-success:focus,.otp-success:focus-within{--input-color:var(--color-success)}.otp-warning,.otp-warning:focus,.otp-warning:focus-within{--input-color:var(--color-warning)}.otp-error,.otp-error:focus,.otp-error:focus-within{--input-color:var(--color-error)}.alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.alert-vertical{text-align:center;grid-template-columns:auto;grid-auto-flow:row;justify-content:center;justify-items:center}.alert-vertical:has(>:nth-child(2)){grid-template-columns:auto}.alert-horizontal{text-align:start;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;justify-items:start}.alert-horizontal:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}.loading-spinner{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='8s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='6s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='6s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}@media (prefers-reduced-motion:no-preference){.loading-spinner{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}}.loading-dots{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='4' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='3s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1'/%3E%3C/circle%3E%3Ccircle cx='12' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='3s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1' begin='0.1s'/%3E%3C/circle%3E%3Ccircle cx='20' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='3s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1' begin='0.2s'/%3E%3C/circle%3E%3C/svg%3E")}@media (prefers-reduced-motion:no-preference){.loading-dots{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='4' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='1.05s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1'/%3E%3C/circle%3E%3Ccircle cx='12' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='1.05s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1' begin='0.1s'/%3E%3C/circle%3E%3Ccircle cx='20' cy='12' r='3'%3E%3Canimate attributeName='cy' values='12;6;12;12' keyTimes='0;0.286;0.571;1' dur='1.05s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1' begin='0.2s'/%3E%3C/circle%3E%3C/svg%3E")}}.loading-ring{mask-image:url("data:image/svg+xml,%3Csvg width='44' height='44' viewBox='0 0 44 44' xmlns='http://www.w3.org/2000/svg' stroke='white'%3E%3Cg fill='none' fill-rule='evenodd' stroke-width='2'%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='0s' dur='5.4s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='0s' dur='5.4s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='-0.9s' dur='5.4s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='-0.9s' dur='5.4s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}@media (prefers-reduced-motion:no-preference){.loading-ring{mask-image:url("data:image/svg+xml,%3Csvg width='44' height='44' viewBox='0 0 44 44' xmlns='http://www.w3.org/2000/svg' stroke='white'%3E%3Cg fill='none' fill-rule='evenodd' stroke-width='2'%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='0s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='0s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3Ccircle cx='22' cy='22' r='1'%3E%3Canimate attributeName='r' begin='-0.9s' dur='1.8s' values='1;20' calcMode='spline' keyTimes='0;1' keySplines='0.165,0.84,0.44,1' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-opacity' begin='-0.9s' dur='1.8s' values='1;0' calcMode='spline' keyTimes='0;1' keySplines='0.3,0.61,0.355,1' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E")}}.loading-ball{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cellipse cx='12' cy='5' rx='4' ry='4'%3E%3Canimate attributeName='cy' values='5;20;20.5;20;5' keyTimes='0;0.469;0.5;0.531;1' dur='2s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1'/%3E%3Canimate attributeName='rx' values='4;4;4.8;4;4' keyTimes='0;0.469;0.5;0.531;1' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='ry' values='4;4;3;4;4' keyTimes='0;0.469;0.5;0.531;1' dur='2s' repeatCount='indefinite'/%3E%3C/ellipse%3E%3C/svg%3E")}@media (prefers-reduced-motion:no-preference){.loading-ball{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cellipse cx='12' cy='5' rx='4' ry='4'%3E%3Canimate attributeName='cy' values='5;20;20.5;20;5' keyTimes='0;0.469;0.5;0.531;1' dur='.8s' repeatCount='indefinite' keySplines='.33,0,.66,.33;.33,.66,.66,1'/%3E%3Canimate attributeName='rx' values='4;4;4.8;4;4' keyTimes='0;0.469;0.5;0.531;1' dur='.8s' repeatCount='indefinite'/%3E%3Canimate attributeName='ry' values='4;4;3;4;4' keyTimes='0;0.469;0.5;0.531;1' dur='.8s' repeatCount='indefinite'/%3E%3C/ellipse%3E%3C/svg%3E")}}.loading-bars{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite'/%3E%3C/rect%3E%3Crect x='9' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite' begin='-0.65s'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite' begin='-0.65s'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite' begin='-0.65s'/%3E%3C/rect%3E%3Crect x='17' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite' begin='-0.5s'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite' begin='-0.5s'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='2.4s' repeatCount='indefinite' begin='-0.5s'/%3E%3C/rect%3E%3C/svg%3E")}@media (prefers-reduced-motion:no-preference){.loading-bars{mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite'/%3E%3C/rect%3E%3Crect x='9' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.65s'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.65s'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.65s'/%3E%3C/rect%3E%3Crect x='17' y='1' width='6' height='22'%3E%3Canimate attributeName='y' values='1;5;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.5s'/%3E%3Canimate attributeName='height' values='22;14;22' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.5s'/%3E%3Canimate attributeName='opacity' values='1;0.2;1' keyTimes='0;0.938;1' dur='.8s' repeatCount='indefinite' begin='-0.5s'/%3E%3C/rect%3E%3C/svg%3E")}}.loading-infinity{mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' style='shape-rendering:auto;' width='200px' height='200px' viewBox='0 0 100 100' preserveAspectRatio='xMidYMid'%3E%3Cpath fill='none' stroke='black' stroke-width='10' stroke-dasharray='205.271 51.318' d='M24.3 30C11.4 30 5 43.3 5 50s6.4 20 19.3 20c19.3 0 32.1-40 51.4-40C88.6 30 95 43.3 95 50s-6.4 20-19.3 20C56.4 70 43.6 30 24.3 30z' stroke-linecap='round' style='transform:scale(0.8);transform-origin:50px 50px'%3E%3Canimate attributeName='stroke-dashoffset' repeatCount='indefinite' dur='6s' keyTimes='0;1' values='0;256.589'/%3E%3C/path%3E%3C/svg%3E")}@media (prefers-reduced-motion:no-preference){.loading-infinity{mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' style='shape-rendering:auto;' width='200px' height='200px' viewBox='0 0 100 100' preserveAspectRatio='xMidYMid'%3E%3Cpath fill='none' stroke='black' stroke-width='10' stroke-dasharray='205.271 51.318' d='M24.3 30C11.4 30 5 43.3 5 50s6.4 20 19.3 20c19.3 0 32.1-40 51.4-40C88.6 30 95 43.3 95 50s-6.4 20-19.3 20C56.4 70 43.6 30 24.3 30z' stroke-linecap='round' style='transform:scale(0.8);transform-origin:50px 50px'%3E%3Canimate attributeName='stroke-dashoffset' repeatCount='indefinite' dur='2s' keyTimes='0;1' values='0;256.589'/%3E%3C/path%3E%3C/svg%3E")}}.loading-xs{width:calc(var(--size-selector,.25rem) * 4)}.loading-sm{width:calc(var(--size-selector,.25rem) * 5)}.loading-md{width:calc(var(--size-selector,.25rem) * 6)}.loading-lg{width:calc(var(--size-selector,.25rem) * 7)}.loading-xl{width:calc(var(--size-selector,.25rem) * 8)}.status-primary{background-color:var(--color-primary);color:var(--color-primary)}.status-secondary{background-color:var(--color-secondary);color:var(--color-secondary)}.status-accent{background-color:var(--color-accent);color:var(--color-accent)}.status-neutral{background-color:var(--color-neutral);color:var(--color-neutral)}.status-info{background-color:var(--color-info);color:var(--color-info)}.status-success{background-color:var(--color-success);color:var(--color-success)}.status-warning{background-color:var(--color-warning);color:var(--color-warning)}.status-error{background-color:var(--color-error);color:var(--color-error)}.status-xs{--size:calc(var(--size-selector,.25rem) * .5)}.status-sm{--size:calc(var(--size-selector,.25rem) * 1)}.status-md{--size:calc(var(--size-selector,.25rem) * 2)}.status-lg{--size:calc(var(--size-selector,.25rem) * 3)}.status-xl{--size:calc(var(--size-selector,.25rem) * 4)}.megamenu-wide{anchor-name:--megamenu}.megamenu-wide:popover-open{inset-inline:0}.megamenu-wide [popover]{position-area:block-end;position-anchor:--megamenu;width:anchor-size(inline)}.megamenu-full{anchor-name:--megamenu}.megamenu-full:popover-open{inset-inline:0}.megamenu-full [popover]{position-area:block-end;position-anchor:--megamenu;border-inline-width:0;border-radius:0;width:100%}.megamenu-vertical{opacity:0;background-color:var(--color-base-100);position-area:block-end;width:100%;--mm-backdrop:transparent;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;border-radius:0;flex-direction:column;align-items:flex-start;position:relative;translate:0 -.5rem;scale:.98}.megamenu-vertical:not([popover]:popover-open){position:revert;display:none}.megamenu-vertical:popover-open{--mm-mt:0;--mm-display:block}.megamenu-vertical [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}.megamenu-vertical [popover]{opacity:1;background-color:#0000;border:none;display:block;position:relative;overflow:visible}.megamenu-xs{--fontsize:.6875rem;--mm-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.megamenu-sm{--fontsize:.75rem;--mm-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.megamenu-md{--fontsize:.875rem;--mm-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.megamenu-lg{--fontsize:1.125rem;--mm-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.megamenu-xl{--fontsize:1.375rem;--mm-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}.indicator-start{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-start{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}.indicator-center{--indicator-s:50%;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-center{--indicator-e:50%;--indicator-x:50%}.indicator-end{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}[dir=rtl] .indicator-end{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}.indicator-bottom{--indicator-t:auto;--indicator-b:0;--indicator-y:50%}.indicator-middle{--indicator-t:50%;--indicator-b:auto;--indicator-y:-50%}.indicator-top{--indicator-t:0;--indicator-b:auto;--indicator-y:-50%}.swap{cursor:pointer;vertical-align:middle;-webkit-user-select:none;user-select:none;place-content:center;display:inline-grid;position:relative}.swap input{appearance:none;border:none}.swap>*{grid-row-start:1;grid-column-start:1;@media (prefers-reduced-motion:no-preference){transition-property:transform,rotate,opacity;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.swap .swap-on,.swap .swap-indeterminate,.swap input:indeterminate~.swap-on,.swap input:is(:checked,:indeterminate)~.swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate{opacity:1;backface-visibility:visible}.divider-horizontal{--divider-m:0 1rem}.divider-horizontal.divider{flex-direction:column;width:1rem;height:auto;&:before,&:after{width:.125rem;height:100%}}.divider-vertical{--divider-m:1rem 0}.divider-vertical.divider{flex-direction:row;width:auto;height:1rem;&:before,&:after{width:100%;height:.125rem}}.divider-neutral:before,.divider-neutral:after{background-color:var(--color-neutral)}.divider-primary:before,.divider-primary:after{background-color:var(--color-primary)}.divider-secondary:before,.divider-secondary:after{background-color:var(--color-secondary)}.divider-accent:before,.divider-accent:after{background-color:var(--color-accent)}.divider-success:before,.divider-success:after{background-color:var(--color-success)}.divider-warning:before,.divider-warning:after{background-color:var(--color-warning)}.divider-info:before,.divider-info:after{background-color:var(--color-info)}.divider-error:before,.divider-error:after{background-color:var(--color-error)}.divider-start:before,.divider-end:after{display:none}.toggle-primary:checked,.toggle-primary[aria-checked=true]{--input-color:var(--color-primary)}.toggle-secondary:checked,.toggle-secondary[aria-checked=true]{--input-color:var(--color-secondary)}.toggle-accent:checked,.toggle-accent[aria-checked=true]{--input-color:var(--color-accent)}.toggle-neutral:checked,.toggle-neutral[aria-checked=true]{--input-color:var(--color-neutral)}.toggle-success:checked,.toggle-success[aria-checked=true]{--input-color:var(--color-success)}.toggle-warning:checked,.toggle-warning[aria-checked=true]{--input-color:var(--color-warning)}.toggle-info:checked,.toggle-info[aria-checked=true]{--input-color:var(--color-info)}.toggle-error:checked,.toggle-error[aria-checked=true]{--input-color:var(--color-error)}.toggle-xs[type=checkbox],.toggle-xs:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 4)}.toggle-sm[type=checkbox],.toggle-sm:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 5)}.toggle-md[type=checkbox],.toggle-md:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 6)}.toggle-lg[type=checkbox],.toggle-lg:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 7)}.toggle-xl[type=checkbox],.toggle-xl:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 8)}.avatar-placeholder>div{justify-content:center;align-items:center;display:flex}.avatar-online:before{content:"";z-index:1;background-color:var(--color-success);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}.avatar-offline:before{content:"";z-index:1;background-color:var(--color-base-300);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 40%, transparent);box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.textarea-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.textarea-ghost:focus,.textarea-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.textarea-neutral,.textarea-neutral:focus,.textarea-neutral:focus-within{--input-color:var(--color-neutral)}.textarea-primary,.textarea-primary:focus,.textarea-primary:focus-within{--input-color:var(--color-primary)}.textarea-secondary,.textarea-secondary:focus,.textarea-secondary:focus-within{--input-color:var(--color-secondary)}.textarea-accent,.textarea-accent:focus,.textarea-accent:focus-within{--input-color:var(--color-accent)}.textarea-info,.textarea-info:focus,.textarea-info:focus-within{--input-color:var(--color-info)}.textarea-success,.textarea-success:focus,.textarea-success:focus-within{--input-color:var(--color-success)}.textarea-warning,.textarea-warning:focus,.textarea-warning:focus-within{--input-color:var(--color-warning)}.textarea-error,.textarea-error:focus,.textarea-error:focus-within{--input-color:var(--color-error)}.textarea-xs{--font-size-min:.6875rem}.floating-label:has(.textarea-xs){--top-mul:3;--font-size:.6875rem}.textarea-sm{--font-size-min:.75rem}.floating-label:has(.textarea-sm){--top-mul:4;--font-size:.75rem}.textarea-md{--font-size-min:.875rem}.floating-label:has(.textarea-md){--top-mul:5;--font-size:.875rem}.textarea-lg{--font-size-min:1.125rem}.floating-label:has(.textarea-lg){--top-mul:6;--font-size:1.125rem}.textarea-xl{--font-size-min:1.375rem}.floating-label:has(.textarea-xl){--top-mul:7;--font-size:1.375rem}.timeline-compact{--timeline-row-start:0}.timeline-compact .timeline-start{grid-area:3/1/4/4;place-self:flex-start center}.timeline-compact li:has(.timeline-start) .timeline-end{grid-row-start:auto;grid-column-start:none}.timeline-compact.timeline-vertical>li{--timeline-col-start:0}.timeline-compact.timeline-vertical .timeline-start{grid-area:1/3/4/4;place-self:center flex-start}.timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-row-start:none;grid-column-start:auto}.timeline-snap-icon>li{--timeline-col-start:.5rem;--timeline-row-start:minmax(0, 1fr)}.timeline-vertical{flex-direction:column}.timeline-vertical>li{--timeline-row-start:minmax(0, 1fr);--timeline-row-end:minmax(0, 1fr);justify-items:center;&>hr{width:.25rem;height:100%;&:first-child{grid-row-start:1;grid-column-start:2}&:last-child{grid-area:3/2/none}}}.timeline-vertical .timeline-start{grid-area:1/1/4/2;place-self:center flex-end}.timeline-vertical .timeline-end{grid-area:1/3/4/4;place-self:center flex-start}.timeline-vertical:has(.timeline-middle)>li>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.timeline-vertical:has(.timeline-middle)>li>hr:last-child,.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector);border-bottom-right-radius:0;border-bottom-left-radius:0}.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.timeline-vertical.timeline-snap-icon>li{--timeline-col-start:minmax(0, 1fr);--timeline-row-start:.5rem}.timeline-horizontal{flex-direction:row}.timeline-horizontal>li{align-items:center;&>hr{width:100%;height:.25rem;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}}}.timeline-horizontal .timeline-start{grid-area:1/1/2/4;place-self:flex-end center}.timeline-horizontal .timeline-end{grid-area:3/1/4/4;place-self:flex-start center}.timeline-horizontal:has(.timeline-middle)>li>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.timeline-horizontal:has(.timeline-middle)>li>hr:last-child,.timeline-horizontal:not(:has(.timeline-middle)) :first-child>hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.timeline-horizontal:not(:has(.timeline-middle)) :last-child>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.stats-horizontal{grid-auto-flow:column;overflow-x:auto}.stats-horizontal .stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.stats-vertical{grid-auto-flow:row;overflow-y:auto}.stats-vertical .stat:not(:last-child){border-inline-end:none;border-block-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000)}.btn-active,.btn:is([aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);isolation:isolate}.btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);--btn-soft-bg:initial}.btn-secondary{--btn-color:var(--color-secondary);--btn-fg:var(--color-secondary-content);--btn-soft-bg:initial}.btn-accent{--btn-color:var(--color-accent);--btn-fg:var(--color-accent-content);--btn-soft-bg:initial}.btn-neutral{--btn-color:var(--color-neutral);--btn-fg:var(--color-neutral-content);--btn-soft-bg:var(--color-neutral-content) 80%;--btn-rest-fg:initial}.btn-info{--btn-color:var(--color-info);--btn-fg:var(--color-info-content);--btn-soft-bg:initial}.btn-success{--btn-color:var(--color-success);--btn-fg:var(--color-success-content);--btn-soft-bg:initial}.btn-warning{--btn-color:var(--color-warning);--btn-fg:var(--color-warning-content);--btn-soft-bg:initial}.btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content);--btn-soft-bg:initial}.btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.btn-md{--fontsize:.875rem;--btn-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.btn-lg{--fontsize:1.125rem;--btn-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.btn-xl{--fontsize:1.375rem;--btn-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}.btn-wide{width:100%;max-width:16rem}.btn-block{width:100%}.btn-square{width:var(--size);height:var(--size);padding-inline:0}.btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}.link-hover{text-decoration-line:none}@media (hover:hover){.link-hover:hover{text-decoration-line:underline}}.link-primary{color:var(--color-primary)}@media (hover:hover){.link-primary:hover{color:color-mix(in oklab, var(--color-primary) 80%, #000)}}.link-secondary{color:var(--color-secondary)}@media (hover:hover){.link-secondary:hover{color:color-mix(in oklab, var(--color-secondary) 80%, #000)}}.link-accent{color:var(--color-accent)}@media (hover:hover){.link-accent:hover{color:color-mix(in oklab, var(--color-accent) 80%, #000)}}.link-neutral{color:var(--color-neutral)}@media (hover:hover){.link-neutral:hover{color:color-mix(in oklab, var(--color-neutral) 80%, #000)}}.link-success{color:var(--color-success)}@media (hover:hover){.link-success:hover{color:color-mix(in oklab, var(--color-success) 80%, #000)}}.link-info{color:var(--color-info)}@media (hover:hover){.link-info:hover{color:color-mix(in oklab, var(--color-info) 80%, #000)}}.link-warning{color:var(--color-warning)}@media (hover:hover){.link-warning:hover{color:color-mix(in oklab, var(--color-warning) 80%, #000)}}.link-error{color:var(--color-error)}@media (hover:hover){.link-error:hover{color:color-mix(in oklab, var(--color-error) 80%, #000)}}.dock>[aria-current]:not([aria-current=false],[aria-current=""]):after,.dock-active:after{color:currentColor;background-color:currentColor;width:2.5rem}.dock-xs{height:3rem;height:calc(3rem + env(safe-area-inset-bottom))}.dock-xs :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.dock-xs .dock-label{font-size:.625rem}.dock-sm{height:3.5rem;height:calc(3.5rem + env(safe-area-inset-bottom))}.dock-sm :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.dock-sm .dock-label{font-size:.625rem}.dock-md{height:4rem;height:calc(4rem + env(safe-area-inset-bottom))}.dock-md .dock-label{font-size:.6875rem}.dock-lg{height:4.5rem;height:calc(4.5rem + env(safe-area-inset-bottom))}.dock-lg :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.dock-lg .dock-label{font-size:.6875rem}.dock-xl{height:5rem;height:calc(5rem + env(safe-area-inset-bottom))}.dock-xl :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.dock-xl .dock-label{font-size:.75rem}.radio-primary{--input-color:var(--color-primary)}.radio-secondary{--input-color:var(--color-secondary)}.radio-accent{--input-color:var(--color-accent)}.radio-neutral{--input-color:var(--color-neutral)}.radio-info{--input-color:var(--color-info)}.radio-success{--input-color:var(--color-success)}.radio-warning{--input-color:var(--color-warning)}.radio-error{--input-color:var(--color-error)}.radio:disabled{cursor:not-allowed;opacity:.2}.radio-xs{padding:.125rem}.radio-xs[type=radio]{--size:calc(var(--size-selector,.25rem) * 4)}.radio-sm{padding:.1875rem}.radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)}.radio-md{padding:.25rem}.radio-md[type=radio]{--size:calc(var(--size-selector,.25rem) * 6)}.radio-lg{padding:.3125rem}.radio-lg[type=radio]{--size:calc(var(--size-selector,.25rem) * 7)}.radio-xl{padding:.375rem}.radio-xl[type=radio]{--size:calc(var(--size-selector,.25rem) * 8)}.progress-primary{color:var(--color-primary)}.progress-secondary{color:var(--color-secondary)}.progress-accent{color:var(--color-accent)}.progress-neutral{color:var(--color-neutral)}.progress-info{color:var(--color-info)}.progress-success{color:var(--color-success)}.progress-warning{color:var(--color-warning)}.progress-error{color:var(--color-error)}.skeleton-text{color:#0000;-webkit-background-clip:text;background-clip:text;background-image:linear-gradient(105deg, color-mix(in oklab, var(--color-base-content) 20%, transparent) 0% 40%, var(--color-base-content) 50%, color-mix(in oklab, var(--color-base-content) 20%, transparent) 60% 100%)}.checkbox-primary{color:var(--color-primary-content);--input-color:var(--color-primary)}.checkbox-secondary{color:var(--color-secondary-content);--input-color:var(--color-secondary)}.checkbox-accent{color:var(--color-accent-content);--input-color:var(--color-accent)}.checkbox-neutral{color:var(--color-neutral-content);--input-color:var(--color-neutral)}.checkbox-info{color:var(--color-info-content);--input-color:var(--color-info)}.checkbox-success{color:var(--color-success-content);--input-color:var(--color-success)}.checkbox-warning{color:var(--color-warning-content);--input-color:var(--color-warning)}.checkbox-error{color:var(--color-error-content);--input-color:var(--color-error)}.checkbox:disabled{cursor:not-allowed;opacity:.2}.checkbox-xs{--size:calc(var(--size-selector,.25rem) * 4);padding:.125rem}.checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5);padding:.1875rem}.checkbox-md{--size:calc(var(--size-selector,.25rem) * 6);padding:.25rem}.checkbox-lg{--size:calc(var(--size-selector,.25rem) * 7);padding:.3125rem}.checkbox-xl{--size:calc(var(--size-selector,.25rem) * 8);padding:.375rem}:where(.drawer-toggle:checked~.drawer-side){pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto;&>:not(.drawer-overlay){translate:0%}}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.drawer-end{grid-auto-columns:auto max-content}.drawer-end>.drawer-toggle~.drawer-content{grid-column-start:1}.drawer-end>.drawer-toggle~.drawer-side{grid-column-start:2;justify-items:end}.drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:100%;[dir=rtl] &{translate:-100%}}.drawer-end>.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.drawer-open>.drawer-side{overflow-y:auto}.drawer-open>.drawer-toggle{display:none;&~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky;&>.drawer-overlay{cursor:default;background-color:#0000}}&:checked~.drawer-side{pointer-events:auto;visibility:visible}}.modal.modal-open,.modal[open],.modal:popover-open,.modal:target,.modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4);&>.modal-box{opacity:1;translate:0;scale:1}:root:has(&){--page-scroll-lock: }}@starting-style{.modal.modal-open,.modal[open],.modal:popover-open,.modal:target,.modal-toggle:checked+.modal{opacity:0}}.modal-top{place-items:start}.modal-top>.modal-box{--modal-tl:0;--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 -100%;scale:1}.modal-middle{place-items:center}.modal-middle>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:91.6667%;max-width:32rem;height:auto;max-height:calc(100vh - 5em);translate:0 2%;scale:.98}.modal-bottom{place-items:end}.modal-bottom>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:0;width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 100%;scale:1}.modal-start{place-items:start}.modal-start>.modal-box{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);width:auto;max-width:none;height:100vh;max-height:none;translate:-100%;scale:1;[dir=rtl] &{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;translate:100%}}.modal-end{place-items:end}.modal-end>.modal-box{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;width:auto;max-width:none;height:100vh;max-height:none;translate:100%;scale:1;[dir=rtl] &{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);translate:-100%}}:is(.validator:user-valid,.validator:has(:user-valid)),:is(.validator:user-valid,.validator:has(:user-valid)):focus,:is(.validator:user-valid,.validator:has(:user-valid)):checked,:is(.validator:user-valid,.validator:has(:user-valid))[aria-checked=true],:is(.validator:user-valid,.validator:has(:user-valid)):focus-within{--input-color:var(--color-success)}:is(.validator:user-invalid,.validator:has(:user-invalid),.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))),:is(.validator:user-invalid,.validator:has(:user-invalid),.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):focus,:is(.validator:user-invalid,.validator:has(:user-invalid),.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):checked,:is(.validator:user-invalid,.validator:has(:user-invalid),.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))[aria-checked=true],:is(.validator:user-invalid,.validator:has(:user-invalid),.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):focus-within{--input-color:var(--color-error)}:is(.validator:user-invalid,.validator:has(:user-invalid),.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))~.validator-hint{visibility:visible;color:var(--color-error)}.footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.footer-center>:not(script,style,template){place-items:center}.footer-horizontal{grid-auto-flow:column}.footer-horizontal.footer-center{grid-auto-flow:dense}.footer-vertical{grid-auto-flow:row}.footer-vertical.footer-center{grid-auto-flow:column dense}.menu-horizontal{flex-direction:row;align-items:flex-start;display:inline-flex}.menu-horizontal>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:0;transform-origin:top;border-radius:var(--radius-box);background-color:var(--color-base-100);opacity:0;margin-top:1rem;padding-block:.5rem;padding-inline-end:.5rem;position:absolute;scale:.95;box-shadow:0 1px 3px oklch(0% 0 0/.1),0 1px 2px -1px oklch(0% 0 0/.1);@media (prefers-reduced-motion:no-preference){@starting-style{opacity:0;scale:.95}transition-behavior:allow-discrete;transition-property:opacity,scale,display;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s menu}}.menu-horizontal>li:not(.menu-title)>details[open]>:is(ul,menu){opacity:1;scale:1}.menu-horizontal>li>details>:is(ul,menu):before{--tw-content:none;content:var(--tw-content)}.menu-vertical{flex-direction:column;align-items:stretch;display:inline-flex}.menu-vertical>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:1rem;background-color:revert-layer;border-radius:revert-layer;animation:revert-layer;box-shadow:revert-layer;margin-top:0;padding-block:0;padding-inline-end:0;transition:revert-layer;position:relative}.menu-paged{--menu-paged-arrow:135deg;--menu-paged-back-arrow:-45deg}[dir=rtl] .menu-paged{--menu-paged-arrow:-45deg;--menu-paged-back-arrow:135deg}.menu-paged :where(li ul,li menu):before{--tw-content:none;content:var(--tw-content)}.menu-paged details[open]>summary{font-size:0;&:not(:active){transition-duration:0s}&>*{display:none}&:before{--tw-content:"Back";content:var(--tw-content);font-size:.875rem}&[aria-label]:before{--tw-content:attr(aria-label);content:var(--tw-content)}}.menu-paged details::details-content{transition:none}.menu-paged:has(>li>details[open])>li:not(:has(>details[open])),.menu-paged :where(:is(ul,menu):has(>li>details[open])>li:not(:has(>details[open]))){display:none}.menu-paged :where(li:has(>details[open]),details[open],details[open]>:is(ul,menu)),.menu-paged :where(details[open])::details-content{display:contents}.menu-paged :where(details[open]:has(>:is(ul,menu)>li>details[open])>summary){display:none}.menu-paged :where(li>details>summary):after{rotate:var(--menu-paged-arrow);transition:none;translate:0}.menu-paged :where(li>details[open]>summary):after{rotate:var(--menu-paged-back-arrow);order:-1;justify-self:flex-start}.menu-xs :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.menu-xs :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.5rem;font-size:.6875rem}.menu-xs details[open]>summary:before{font-size:.6875rem}.menu-xs .menu-title{padding-block:.25rem;padding-inline:.5rem}.menu-sm :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.625rem;font-size:.75rem}.menu-sm details[open]>summary:before{font-size:.75rem}.menu-sm .menu-title{padding-block:.5rem;padding-inline:.75rem}.menu-md :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.menu-md :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:.75rem;font-size:.875rem}.menu-md details[open]>summary:before{font-size:.875rem}.menu-md .menu-title{padding-block:.5rem;padding-inline:.75rem}.menu-lg :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.menu-lg :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1rem;font-size:1.125rem}.menu-lg details[open]>summary:before{font-size:1.125rem}.menu-lg .menu-title{padding-block:.75rem;padding-inline:1.5rem}.menu-xl :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.menu-xl :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1.25rem;font-size:1.375rem}.menu-xl details[open]>summary:before{font-size:1.375rem}.menu-xl .menu-title{padding-block:.75rem;padding-inline:1.5rem}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{outline-offset:2px;outline:2px solid #0000}}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}.chat-bubble-primary{background-color:var(--color-primary);color:var(--color-primary-content)}.chat-bubble-secondary{background-color:var(--color-secondary);color:var(--color-secondary-content)}.chat-bubble-accent{background-color:var(--color-accent);color:var(--color-accent-content)}.chat-bubble-neutral{background-color:var(--color-neutral);color:var(--color-neutral-content)}.chat-bubble-info{background-color:var(--color-info);color:var(--color-info-content)}.chat-bubble-success{background-color:var(--color-success);color:var(--color-success-content)}.chat-bubble-warning{background-color:var(--color-warning);color:var(--color-warning-content)}.chat-bubble-error{background-color:var(--color-error);color:var(--color-error-content)}.chat-start{grid-template-columns:auto 1fr;place-items:start}.chat-start .chat-header,.chat-start .chat-footer{grid-column-start:2}.chat-start .chat-image{grid-column-start:1}.chat-start .chat-bubble{border-end-start-radius:0;grid-column-start:2;&:before{inset-inline-start:-.75rem;transform:rotateY(0)}[dir=rtl] &:before{transform:rotateY(180deg)}}.chat-end{grid-template-columns:1fr auto;place-items:end}.chat-end .chat-header,.chat-end .chat-footer{grid-column-start:1}.chat-end .chat-image{grid-column-start:2}.chat-end .chat-bubble{border-end-end-radius:0;grid-column-start:1;&:before{inset-inline-start:100%;transform:rotateY(180deg)}[dir=rtl] &:before{transform:rotateY(0)}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-radius:min(var(--radius-field), .8rem);--tt-overlap:clamp(1px, calc(var(--radius-field) / 8), 2px);--tt-tail:calc(100% + var(--tt-overlap) + .25rem);--tt-tail-off:max(.5rem, var(--tt-radius));display:inline-block;position:relative}.sm\:tooltip>.tooltip-content,.sm\:tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;font-weight:400;line-height:1.25;position:absolute}.sm\:tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;mask-position:-1px 0;mask-repeat:no-repeat;mask-image:var(--mask-tooltip);transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%;display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.sm\:tooltip>.tooltip-content,.sm\:tooltip[data-tip]:before,.sm\:tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem;@media (prefers-reduced-motion:no-preference){transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}}@layer daisyui.l1.l2{.sm\:tooltip-top>.tooltip-content,.sm\:tooltip-top[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.sm\:tooltip-top:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.sm\:tooltip-bottom>.tooltip-content,.sm\:tooltip-bottom[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem));inset:var(--tt-off) auto auto 50%}.sm\:tooltip-bottom:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem)) rotate(180deg);inset:var(--tt-tail) auto auto 50%}.sm\:tooltip-start{--tt-trans:0;--tt-inset:0 auto;--tt-tail-inset:var(--tt-tail-off) auto}.sm\:tooltip-start>.tooltip-content,.sm\:tooltip-start[data-tip]:before{left:auto;right:auto;inset-inline:0 auto}.sm\:tooltip-start:after{left:auto;right:auto;inset-inline:var(--tt-tail-off) auto}.sm\:tooltip-center{--tt-trans:-50%;--tt-inset:50% auto;--tt-tail-inset:50% auto}.sm\:tooltip-center>.tooltip-content,.sm\:tooltip-center[data-tip]:before,.sm\:tooltip-center:after{inset-inline:auto;left:50%;right:auto}.sm\:tooltip-end{--tt-trans:0;--tt-inset:auto 0;--tt-tail-inset:auto var(--tt-tail-off)}.sm\:tooltip-end>.tooltip-content,.sm\:tooltip-end[data-tip]:before{left:auto;right:auto;inset-inline:auto 0}.sm\:tooltip-end:after{left:auto;right:auto;inset-inline:auto var(--tt-tail-off)}.sm\:tooltip-left{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.sm\:tooltip-left>.tooltip-content,.sm\:tooltip-left[data-tip]:before{transform:translateX(calc(var(--tt-pos,.25rem) - .25rem)) translateY(var(--tt-trans,-50%));left:auto;right:var(--tt-off);inset-block:var(--tt-inset,50% auto)}.sm\:tooltip-left:after{transform:translateX(var(--tt-pos,.25rem)) translateY(var(--tt-trans,-50%)) rotate(-90deg);left:auto;right:calc(var(--tt-tail) + 1px);inset-block:var(--tt-tail-inset,50% auto)}.sm\:tooltip-right{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.sm\:tooltip-right>.tooltip-content,.sm\:tooltip-right[data-tip]:before{transform:translateX(calc(var(--tt-pos,-.25rem) + .25rem)) translateY(var(--tt-trans,-50%));left:var(--tt-off);right:auto;inset-block:var(--tt-inset,50% auto)}.sm\:tooltip-right:after{transform:translateX(var(--tt-pos,-.25rem)) translateY(var(--tt-trans,-50%)) rotate(90deg);left:calc(var(--tt-tail) + 1px);right:auto;inset-block:var(--tt-tail-inset,50% auto)}.sm\:tooltip-primary{--tt-bg:var(--color-primary)}.sm\:tooltip-primary>.tooltip-content,.sm\:tooltip-primary[data-tip]:before{color:var(--color-primary-content)}.sm\:tooltip-secondary{--tt-bg:var(--color-secondary)}.sm\:tooltip-secondary>.tooltip-content,.sm\:tooltip-secondary[data-tip]:before{color:var(--color-secondary-content)}.sm\:tooltip-accent{--tt-bg:var(--color-accent)}.sm\:tooltip-accent>.tooltip-content,.sm\:tooltip-accent[data-tip]:before{color:var(--color-accent-content)}.sm\:tooltip-info{--tt-bg:var(--color-info)}.sm\:tooltip-info>.tooltip-content,.sm\:tooltip-info[data-tip]:before{color:var(--color-info-content)}.sm\:tooltip-success{--tt-bg:var(--color-success)}.sm\:tooltip-success>.tooltip-content,.sm\:tooltip-success[data-tip]:before{color:var(--color-success-content)}.sm\:tooltip-warning{--tt-bg:var(--color-warning)}.sm\:tooltip-warning>.tooltip-content,.sm\:tooltip-warning[data-tip]:before{color:var(--color-warning-content)}.sm\:tooltip-error{--tt-bg:var(--color-error)}.sm\:tooltip-error>.tooltip-content,.sm\:tooltip-error[data-tip]:before{color:var(--color-error-content)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-radius:min(var(--radius-field), .8rem);--tt-overlap:clamp(1px, calc(var(--radius-field) / 8), 2px);--tt-tail:calc(100% + var(--tt-overlap) + .25rem);--tt-tail-off:max(.5rem, var(--tt-radius));display:inline-block;position:relative}.md\:tooltip>.tooltip-content,.md\:tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;font-weight:400;line-height:1.25;position:absolute}.md\:tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;mask-position:-1px 0;mask-repeat:no-repeat;mask-image:var(--mask-tooltip);transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%;display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.md\:tooltip>.tooltip-content,.md\:tooltip[data-tip]:before,.md\:tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem;@media (prefers-reduced-motion:no-preference){transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}}@layer daisyui.l1.l2{.md\:tooltip-top>.tooltip-content,.md\:tooltip-top[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.md\:tooltip-top:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.md\:tooltip-bottom>.tooltip-content,.md\:tooltip-bottom[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem));inset:var(--tt-off) auto auto 50%}.md\:tooltip-bottom:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem)) rotate(180deg);inset:var(--tt-tail) auto auto 50%}.md\:tooltip-start{--tt-trans:0;--tt-inset:0 auto;--tt-tail-inset:var(--tt-tail-off) auto}.md\:tooltip-start>.tooltip-content,.md\:tooltip-start[data-tip]:before{left:auto;right:auto;inset-inline:0 auto}.md\:tooltip-start:after{left:auto;right:auto;inset-inline:var(--tt-tail-off) auto}.md\:tooltip-center{--tt-trans:-50%;--tt-inset:50% auto;--tt-tail-inset:50% auto}.md\:tooltip-center>.tooltip-content,.md\:tooltip-center[data-tip]:before,.md\:tooltip-center:after{inset-inline:auto;left:50%;right:auto}.md\:tooltip-end{--tt-trans:0;--tt-inset:auto 0;--tt-tail-inset:auto var(--tt-tail-off)}.md\:tooltip-end>.tooltip-content,.md\:tooltip-end[data-tip]:before{left:auto;right:auto;inset-inline:auto 0}.md\:tooltip-end:after{left:auto;right:auto;inset-inline:auto var(--tt-tail-off)}.md\:tooltip-left{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.md\:tooltip-left>.tooltip-content,.md\:tooltip-left[data-tip]:before{transform:translateX(calc(var(--tt-pos,.25rem) - .25rem)) translateY(var(--tt-trans,-50%));left:auto;right:var(--tt-off);inset-block:var(--tt-inset,50% auto)}.md\:tooltip-left:after{transform:translateX(var(--tt-pos,.25rem)) translateY(var(--tt-trans,-50%)) rotate(-90deg);left:auto;right:calc(var(--tt-tail) + 1px);inset-block:var(--tt-tail-inset,50% auto)}.md\:tooltip-right{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.md\:tooltip-right>.tooltip-content,.md\:tooltip-right[data-tip]:before{transform:translateX(calc(var(--tt-pos,-.25rem) + .25rem)) translateY(var(--tt-trans,-50%));left:var(--tt-off);right:auto;inset-block:var(--tt-inset,50% auto)}.md\:tooltip-right:after{transform:translateX(var(--tt-pos,-.25rem)) translateY(var(--tt-trans,-50%)) rotate(90deg);left:calc(var(--tt-tail) + 1px);right:auto;inset-block:var(--tt-tail-inset,50% auto)}.md\:tooltip-primary{--tt-bg:var(--color-primary)}.md\:tooltip-primary>.tooltip-content,.md\:tooltip-primary[data-tip]:before{color:var(--color-primary-content)}.md\:tooltip-secondary{--tt-bg:var(--color-secondary)}.md\:tooltip-secondary>.tooltip-content,.md\:tooltip-secondary[data-tip]:before{color:var(--color-secondary-content)}.md\:tooltip-accent{--tt-bg:var(--color-accent)}.md\:tooltip-accent>.tooltip-content,.md\:tooltip-accent[data-tip]:before{color:var(--color-accent-content)}.md\:tooltip-info{--tt-bg:var(--color-info)}.md\:tooltip-info>.tooltip-content,.md\:tooltip-info[data-tip]:before{color:var(--color-info-content)}.md\:tooltip-success{--tt-bg:var(--color-success)}.md\:tooltip-success>.tooltip-content,.md\:tooltip-success[data-tip]:before{color:var(--color-success-content)}.md\:tooltip-warning{--tt-bg:var(--color-warning)}.md\:tooltip-warning>.tooltip-content,.md\:tooltip-warning[data-tip]:before{color:var(--color-warning-content)}.md\:tooltip-error{--tt-bg:var(--color-error)}.md\:tooltip-error>.tooltip-content,.md\:tooltip-error[data-tip]:before{color:var(--color-error-content)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-radius:min(var(--radius-field), .8rem);--tt-overlap:clamp(1px, calc(var(--radius-field) / 8), 2px);--tt-tail:calc(100% + var(--tt-overlap) + .25rem);--tt-tail-off:max(.5rem, var(--tt-radius));display:inline-block;position:relative}.lg\:tooltip>.tooltip-content,.lg\:tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;font-weight:400;line-height:1.25;position:absolute}.lg\:tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;mask-position:-1px 0;mask-repeat:no-repeat;mask-image:var(--mask-tooltip);transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%;display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.lg\:tooltip>.tooltip-content,.lg\:tooltip[data-tip]:before,.lg\:tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem;@media (prefers-reduced-motion:no-preference){transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}}@layer daisyui.l1.l2{.lg\:tooltip-top>.tooltip-content,.lg\:tooltip-top[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.lg\:tooltip-top:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.lg\:tooltip-bottom>.tooltip-content,.lg\:tooltip-bottom[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem));inset:var(--tt-off) auto auto 50%}.lg\:tooltip-bottom:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem)) rotate(180deg);inset:var(--tt-tail) auto auto 50%}.lg\:tooltip-start{--tt-trans:0;--tt-inset:0 auto;--tt-tail-inset:var(--tt-tail-off) auto}.lg\:tooltip-start>.tooltip-content,.lg\:tooltip-start[data-tip]:before{left:auto;right:auto;inset-inline:0 auto}.lg\:tooltip-start:after{left:auto;right:auto;inset-inline:var(--tt-tail-off) auto}.lg\:tooltip-center{--tt-trans:-50%;--tt-inset:50% auto;--tt-tail-inset:50% auto}.lg\:tooltip-center>.tooltip-content,.lg\:tooltip-center[data-tip]:before,.lg\:tooltip-center:after{inset-inline:auto;left:50%;right:auto}.lg\:tooltip-end{--tt-trans:0;--tt-inset:auto 0;--tt-tail-inset:auto var(--tt-tail-off)}.lg\:tooltip-end>.tooltip-content,.lg\:tooltip-end[data-tip]:before{left:auto;right:auto;inset-inline:auto 0}.lg\:tooltip-end:after{left:auto;right:auto;inset-inline:auto var(--tt-tail-off)}.lg\:tooltip-left{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.lg\:tooltip-left>.tooltip-content,.lg\:tooltip-left[data-tip]:before{transform:translateX(calc(var(--tt-pos,.25rem) - .25rem)) translateY(var(--tt-trans,-50%));left:auto;right:var(--tt-off);inset-block:var(--tt-inset,50% auto)}.lg\:tooltip-left:after{transform:translateX(var(--tt-pos,.25rem)) translateY(var(--tt-trans,-50%)) rotate(-90deg);left:auto;right:calc(var(--tt-tail) + 1px);inset-block:var(--tt-tail-inset,50% auto)}.lg\:tooltip-right{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.lg\:tooltip-right>.tooltip-content,.lg\:tooltip-right[data-tip]:before{transform:translateX(calc(var(--tt-pos,-.25rem) + .25rem)) translateY(var(--tt-trans,-50%));left:var(--tt-off);right:auto;inset-block:var(--tt-inset,50% auto)}.lg\:tooltip-right:after{transform:translateX(var(--tt-pos,-.25rem)) translateY(var(--tt-trans,-50%)) rotate(90deg);left:calc(var(--tt-tail) + 1px);right:auto;inset-block:var(--tt-tail-inset,50% auto)}.lg\:tooltip-primary{--tt-bg:var(--color-primary)}.lg\:tooltip-primary>.tooltip-content,.lg\:tooltip-primary[data-tip]:before{color:var(--color-primary-content)}.lg\:tooltip-secondary{--tt-bg:var(--color-secondary)}.lg\:tooltip-secondary>.tooltip-content,.lg\:tooltip-secondary[data-tip]:before{color:var(--color-secondary-content)}.lg\:tooltip-accent{--tt-bg:var(--color-accent)}.lg\:tooltip-accent>.tooltip-content,.lg\:tooltip-accent[data-tip]:before{color:var(--color-accent-content)}.lg\:tooltip-info{--tt-bg:var(--color-info)}.lg\:tooltip-info>.tooltip-content,.lg\:tooltip-info[data-tip]:before{color:var(--color-info-content)}.lg\:tooltip-success{--tt-bg:var(--color-success)}.lg\:tooltip-success>.tooltip-content,.lg\:tooltip-success[data-tip]:before{color:var(--color-success-content)}.lg\:tooltip-warning{--tt-bg:var(--color-warning)}.lg\:tooltip-warning>.tooltip-content,.lg\:tooltip-warning[data-tip]:before{color:var(--color-warning-content)}.lg\:tooltip-error{--tt-bg:var(--color-error)}.lg\:tooltip-error>.tooltip-content,.lg\:tooltip-error[data-tip]:before{color:var(--color-error-content)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-radius:min(var(--radius-field), .8rem);--tt-overlap:clamp(1px, calc(var(--radius-field) / 8), 2px);--tt-tail:calc(100% + var(--tt-overlap) + .25rem);--tt-tail-off:max(.5rem, var(--tt-radius));display:inline-block;position:relative}.xl\:tooltip>.tooltip-content,.xl\:tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;font-weight:400;line-height:1.25;position:absolute}.xl\:tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;mask-position:-1px 0;mask-repeat:no-repeat;mask-image:var(--mask-tooltip);transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%;display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.xl\:tooltip>.tooltip-content,.xl\:tooltip[data-tip]:before,.xl\:tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem;@media (prefers-reduced-motion:no-preference){transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}}@layer daisyui.l1.l2{.xl\:tooltip-top>.tooltip-content,.xl\:tooltip-top[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.xl\:tooltip-top:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.xl\:tooltip-bottom>.tooltip-content,.xl\:tooltip-bottom[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem));inset:var(--tt-off) auto auto 50%}.xl\:tooltip-bottom:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem)) rotate(180deg);inset:var(--tt-tail) auto auto 50%}.xl\:tooltip-start{--tt-trans:0;--tt-inset:0 auto;--tt-tail-inset:var(--tt-tail-off) auto}.xl\:tooltip-start>.tooltip-content,.xl\:tooltip-start[data-tip]:before{left:auto;right:auto;inset-inline:0 auto}.xl\:tooltip-start:after{left:auto;right:auto;inset-inline:var(--tt-tail-off) auto}.xl\:tooltip-center{--tt-trans:-50%;--tt-inset:50% auto;--tt-tail-inset:50% auto}.xl\:tooltip-center>.tooltip-content,.xl\:tooltip-center[data-tip]:before,.xl\:tooltip-center:after{inset-inline:auto;left:50%;right:auto}.xl\:tooltip-end{--tt-trans:0;--tt-inset:auto 0;--tt-tail-inset:auto var(--tt-tail-off)}.xl\:tooltip-end>.tooltip-content,.xl\:tooltip-end[data-tip]:before{left:auto;right:auto;inset-inline:auto 0}.xl\:tooltip-end:after{left:auto;right:auto;inset-inline:auto var(--tt-tail-off)}.xl\:tooltip-left{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.xl\:tooltip-left>.tooltip-content,.xl\:tooltip-left[data-tip]:before{transform:translateX(calc(var(--tt-pos,.25rem) - .25rem)) translateY(var(--tt-trans,-50%));left:auto;right:var(--tt-off);inset-block:var(--tt-inset,50% auto)}.xl\:tooltip-left:after{transform:translateX(var(--tt-pos,.25rem)) translateY(var(--tt-trans,-50%)) rotate(-90deg);left:auto;right:calc(var(--tt-tail) + 1px);inset-block:var(--tt-tail-inset,50% auto)}.xl\:tooltip-right{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.xl\:tooltip-right>.tooltip-content,.xl\:tooltip-right[data-tip]:before{transform:translateX(calc(var(--tt-pos,-.25rem) + .25rem)) translateY(var(--tt-trans,-50%));left:var(--tt-off);right:auto;inset-block:var(--tt-inset,50% auto)}.xl\:tooltip-right:after{transform:translateX(var(--tt-pos,-.25rem)) translateY(var(--tt-trans,-50%)) rotate(90deg);left:calc(var(--tt-tail) + 1px);right:auto;inset-block:var(--tt-tail-inset,50% auto)}.xl\:tooltip-primary{--tt-bg:var(--color-primary)}.xl\:tooltip-primary>.tooltip-content,.xl\:tooltip-primary[data-tip]:before{color:var(--color-primary-content)}.xl\:tooltip-secondary{--tt-bg:var(--color-secondary)}.xl\:tooltip-secondary>.tooltip-content,.xl\:tooltip-secondary[data-tip]:before{color:var(--color-secondary-content)}.xl\:tooltip-accent{--tt-bg:var(--color-accent)}.xl\:tooltip-accent>.tooltip-content,.xl\:tooltip-accent[data-tip]:before{color:var(--color-accent-content)}.xl\:tooltip-info{--tt-bg:var(--color-info)}.xl\:tooltip-info>.tooltip-content,.xl\:tooltip-info[data-tip]:before{color:var(--color-info-content)}.xl\:tooltip-success{--tt-bg:var(--color-success)}.xl\:tooltip-success>.tooltip-content,.xl\:tooltip-success[data-tip]:before{color:var(--color-success-content)}.xl\:tooltip-warning{--tt-bg:var(--color-warning)}.xl\:tooltip-warning>.tooltip-content,.xl\:tooltip-warning[data-tip]:before{color:var(--color-warning-content)}.xl\:tooltip-error{--tt-bg:var(--color-error)}.xl\:tooltip-error>.tooltip-content,.xl\:tooltip-error[data-tip]:before{color:var(--color-error-content)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-radius:min(var(--radius-field), .8rem);--tt-overlap:clamp(1px, calc(var(--radius-field) / 8), 2px);--tt-tail:calc(100% + var(--tt-overlap) + .25rem);--tt-tail-off:max(.5rem, var(--tt-radius));display:inline-block;position:relative}.\32 xl\:tooltip>.tooltip-content,.\32 xl\:tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;font-weight:400;line-height:1.25;position:absolute}.\32 xl\:tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;mask-position:-1px 0;mask-repeat:no-repeat;mask-image:var(--mask-tooltip);transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%;display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.\32 xl\:tooltip>.tooltip-content,.\32 xl\:tooltip[data-tip]:before,.\32 xl\:tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem;@media (prefers-reduced-motion:no-preference){transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}}@layer daisyui.l1.l2{.\32 xl\:tooltip-top>.tooltip-content,.\32 xl\:tooltip-top[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.\32 xl\:tooltip-top:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.\32 xl\:tooltip-bottom>.tooltip-content,.\32 xl\:tooltip-bottom[data-tip]:before{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem));inset:var(--tt-off) auto auto 50%}.\32 xl\:tooltip-bottom:after{transform:translateX(var(--tt-trans,-50%)) translateY(var(--tt-pos,-.25rem)) rotate(180deg);inset:var(--tt-tail) auto auto 50%}.\32 xl\:tooltip-start{--tt-trans:0;--tt-inset:0 auto;--tt-tail-inset:var(--tt-tail-off) auto}.\32 xl\:tooltip-start>.tooltip-content,.\32 xl\:tooltip-start[data-tip]:before{left:auto;right:auto;inset-inline:0 auto}.\32 xl\:tooltip-start:after{left:auto;right:auto;inset-inline:var(--tt-tail-off) auto}.\32 xl\:tooltip-center{--tt-trans:-50%;--tt-inset:50% auto;--tt-tail-inset:50% auto}.\32 xl\:tooltip-center>.tooltip-content,.\32 xl\:tooltip-center[data-tip]:before,.\32 xl\:tooltip-center:after{inset-inline:auto;left:50%;right:auto}.\32 xl\:tooltip-end{--tt-trans:0;--tt-inset:auto 0;--tt-tail-inset:auto var(--tt-tail-off)}.\32 xl\:tooltip-end>.tooltip-content,.\32 xl\:tooltip-end[data-tip]:before{left:auto;right:auto;inset-inline:auto 0}.\32 xl\:tooltip-end:after{left:auto;right:auto;inset-inline:auto var(--tt-tail-off)}.\32 xl\:tooltip-left{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.\32 xl\:tooltip-left>.tooltip-content,.\32 xl\:tooltip-left[data-tip]:before{transform:translateX(calc(var(--tt-pos,.25rem) - .25rem)) translateY(var(--tt-trans,-50%));left:auto;right:var(--tt-off);inset-block:var(--tt-inset,50% auto)}.\32 xl\:tooltip-left:after{transform:translateX(var(--tt-pos,.25rem)) translateY(var(--tt-trans,-50%)) rotate(-90deg);left:auto;right:calc(var(--tt-tail) + 1px);inset-block:var(--tt-tail-inset,50% auto)}.\32 xl\:tooltip-right{--tt-tail-off:clamp(.5rem, var(--tt-radius) + 3px, .8rem - 2px)}.\32 xl\:tooltip-right>.tooltip-content,.\32 xl\:tooltip-right[data-tip]:before{transform:translateX(calc(var(--tt-pos,-.25rem) + .25rem)) translateY(var(--tt-trans,-50%));left:var(--tt-off);right:auto;inset-block:var(--tt-inset,50% auto)}.\32 xl\:tooltip-right:after{transform:translateX(var(--tt-pos,-.25rem)) translateY(var(--tt-trans,-50%)) rotate(90deg);left:calc(var(--tt-tail) + 1px);right:auto;inset-block:var(--tt-tail-inset,50% auto)}.\32 xl\:tooltip-primary{--tt-bg:var(--color-primary)}.\32 xl\:tooltip-primary>.tooltip-content,.\32 xl\:tooltip-primary[data-tip]:before{color:var(--color-primary-content)}.\32 xl\:tooltip-secondary{--tt-bg:var(--color-secondary)}.\32 xl\:tooltip-secondary>.tooltip-content,.\32 xl\:tooltip-secondary[data-tip]:before{color:var(--color-secondary-content)}.\32 xl\:tooltip-accent{--tt-bg:var(--color-accent)}.\32 xl\:tooltip-accent>.tooltip-content,.\32 xl\:tooltip-accent[data-tip]:before{color:var(--color-accent-content)}.\32 xl\:tooltip-info{--tt-bg:var(--color-info)}.\32 xl\:tooltip-info>.tooltip-content,.\32 xl\:tooltip-info[data-tip]:before{color:var(--color-info-content)}.\32 xl\:tooltip-success{--tt-bg:var(--color-success)}.\32 xl\:tooltip-success>.tooltip-content,.\32 xl\:tooltip-success[data-tip]:before{color:var(--color-success-content)}.\32 xl\:tooltip-warning{--tt-bg:var(--color-warning)}.\32 xl\:tooltip-warning>.tooltip-content,.\32 xl\:tooltip-warning[data-tip]:before{color:var(--color-warning-content)}.\32 xl\:tooltip-error{--tt-bg:var(--color-error)}.\32 xl\:tooltip-error>.tooltip-content,.\32 xl\:tooltip-error[data-tip]:before{color:var(--color-error-content)}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:table{border-collapse:separate;--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.sm\:table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(:is(.table tr.row-hover),.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.sm\:table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.sm\:table :where(thead,tfoot){white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);font-size:.875rem;font-weight:600}.sm\:table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}.sm\:table :where(.table-pin-rows thead){z-index:1;position:sticky;top:0}.sm\:table :where(.table-pin-rows tfoot){z-index:1;position:sticky;bottom:0}.sm\:table :where(.table-pin-rows :is(thead,tfoot) tr){background-color:var(--color-base-100)}.sm\:table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.sm\:table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}@layer daisyui.l1.l2{.sm\:table-zebra tbody tr:where(:nth-child(2n)){background-color:var(--color-base-200);& :where(.table-pin-cols tr th){background-color:var(--color-base-200)}}@media (hover:hover){:is(:is(.table-zebra tbody tr.row-hover),.table-zebra tbody tr.row-hover:where(:nth-child(2n))):hover{background-color:var(--color-base-300)}}.sm\:table-xs :not(thead,tfoot) tr{font-size:.6875rem}.sm\:table-xs :where(th,td){padding-block:.25rem;padding-inline:.5rem}.sm\:table-sm :not(thead,tfoot) tr{font-size:.75rem}.sm\:table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.sm\:table-md :not(thead,tfoot) tr{font-size:.875rem}.sm\:table-md :where(th,td){padding-block:.75rem;padding-inline:1rem}.sm\:table-lg :not(thead,tfoot) tr{font-size:1.125rem}.sm\:table-lg :where(th,td){padding-block:1rem;padding-inline:1.25rem}.sm\:table-xl :not(thead,tfoot) tr{font-size:1.375rem}.sm\:table-xl :where(th,td){padding-block:1.25rem;padding-inline:1.5rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:table{border-collapse:separate;--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.md\:table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(:is(.table tr.row-hover),.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.md\:table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.md\:table :where(thead,tfoot){white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);font-size:.875rem;font-weight:600}.md\:table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}.md\:table :where(.table-pin-rows thead){z-index:1;position:sticky;top:0}.md\:table :where(.table-pin-rows tfoot){z-index:1;position:sticky;bottom:0}.md\:table :where(.table-pin-rows :is(thead,tfoot) tr){background-color:var(--color-base-100)}.md\:table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.md\:table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}@layer daisyui.l1.l2{.md\:table-zebra tbody tr:where(:nth-child(2n)){background-color:var(--color-base-200);& :where(.table-pin-cols tr th){background-color:var(--color-base-200)}}@media (hover:hover){:is(:is(.table-zebra tbody tr.row-hover),.table-zebra tbody tr.row-hover:where(:nth-child(2n))):hover{background-color:var(--color-base-300)}}.md\:table-xs :not(thead,tfoot) tr{font-size:.6875rem}.md\:table-xs :where(th,td){padding-block:.25rem;padding-inline:.5rem}.md\:table-sm :not(thead,tfoot) tr{font-size:.75rem}.md\:table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.md\:table-md :not(thead,tfoot) tr{font-size:.875rem}.md\:table-md :where(th,td){padding-block:.75rem;padding-inline:1rem}.md\:table-lg :not(thead,tfoot) tr{font-size:1.125rem}.md\:table-lg :where(th,td){padding-block:1rem;padding-inline:1.25rem}.md\:table-xl :not(thead,tfoot) tr{font-size:1.375rem}.md\:table-xl :where(th,td){padding-block:1.25rem;padding-inline:1.5rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:table{border-collapse:separate;--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.lg\:table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(:is(.table tr.row-hover),.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.lg\:table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.lg\:table :where(thead,tfoot){white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);font-size:.875rem;font-weight:600}.lg\:table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}.lg\:table :where(.table-pin-rows thead){z-index:1;position:sticky;top:0}.lg\:table :where(.table-pin-rows tfoot){z-index:1;position:sticky;bottom:0}.lg\:table :where(.table-pin-rows :is(thead,tfoot) tr){background-color:var(--color-base-100)}.lg\:table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.lg\:table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}@layer daisyui.l1.l2{.lg\:table-zebra tbody tr:where(:nth-child(2n)){background-color:var(--color-base-200);& :where(.table-pin-cols tr th){background-color:var(--color-base-200)}}@media (hover:hover){:is(:is(.table-zebra tbody tr.row-hover),.table-zebra tbody tr.row-hover:where(:nth-child(2n))):hover{background-color:var(--color-base-300)}}.lg\:table-xs :not(thead,tfoot) tr{font-size:.6875rem}.lg\:table-xs :where(th,td){padding-block:.25rem;padding-inline:.5rem}.lg\:table-sm :not(thead,tfoot) tr{font-size:.75rem}.lg\:table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.lg\:table-md :not(thead,tfoot) tr{font-size:.875rem}.lg\:table-md :where(th,td){padding-block:.75rem;padding-inline:1rem}.lg\:table-lg :not(thead,tfoot) tr{font-size:1.125rem}.lg\:table-lg :where(th,td){padding-block:1rem;padding-inline:1.25rem}.lg\:table-xl :not(thead,tfoot) tr{font-size:1.375rem}.lg\:table-xl :where(th,td){padding-block:1.25rem;padding-inline:1.5rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:table{border-collapse:separate;--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.xl\:table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(:is(.table tr.row-hover),.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.xl\:table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.xl\:table :where(thead,tfoot){white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);font-size:.875rem;font-weight:600}.xl\:table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}.xl\:table :where(.table-pin-rows thead){z-index:1;position:sticky;top:0}.xl\:table :where(.table-pin-rows tfoot){z-index:1;position:sticky;bottom:0}.xl\:table :where(.table-pin-rows :is(thead,tfoot) tr){background-color:var(--color-base-100)}.xl\:table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.xl\:table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}@layer daisyui.l1.l2{.xl\:table-zebra tbody tr:where(:nth-child(2n)){background-color:var(--color-base-200);& :where(.table-pin-cols tr th){background-color:var(--color-base-200)}}@media (hover:hover){:is(:is(.table-zebra tbody tr.row-hover),.table-zebra tbody tr.row-hover:where(:nth-child(2n))):hover{background-color:var(--color-base-300)}}.xl\:table-xs :not(thead,tfoot) tr{font-size:.6875rem}.xl\:table-xs :where(th,td){padding-block:.25rem;padding-inline:.5rem}.xl\:table-sm :not(thead,tfoot) tr{font-size:.75rem}.xl\:table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.xl\:table-md :not(thead,tfoot) tr{font-size:.875rem}.xl\:table-md :where(th,td){padding-block:.75rem;padding-inline:1rem}.xl\:table-lg :not(thead,tfoot) tr{font-size:1.125rem}.xl\:table-lg :where(th,td){padding-block:1rem;padding-inline:1.25rem}.xl\:table-xl :not(thead,tfoot) tr{font-size:1.375rem}.xl\:table-xl :where(th,td){padding-block:1.25rem;padding-inline:1.5rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:table{border-collapse:separate;--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.\32 xl\:table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(:is(.table tr.row-hover),.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.\32 xl\:table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.\32 xl\:table :where(thead,tfoot){white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);font-size:.875rem;font-weight:600}.\32 xl\:table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}.\32 xl\:table :where(.table-pin-rows thead){z-index:1;position:sticky;top:0}.\32 xl\:table :where(.table-pin-rows tfoot){z-index:1;position:sticky;bottom:0}.\32 xl\:table :where(.table-pin-rows :is(thead,tfoot) tr){background-color:var(--color-base-100)}.\32 xl\:table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.\32 xl\:table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}@layer daisyui.l1.l2{.\32 xl\:table-zebra tbody tr:where(:nth-child(2n)){background-color:var(--color-base-200);& :where(.table-pin-cols tr th){background-color:var(--color-base-200)}}@media (hover:hover){:is(:is(.table-zebra tbody tr.row-hover),.table-zebra tbody tr.row-hover:where(:nth-child(2n))):hover{background-color:var(--color-base-300)}}.\32 xl\:table-xs :not(thead,tfoot) tr{font-size:.6875rem}.\32 xl\:table-xs :where(th,td){padding-block:.25rem;padding-inline:.5rem}.\32 xl\:table-sm :not(thead,tfoot) tr{font-size:.75rem}.\32 xl\:table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.\32 xl\:table-md :not(thead,tfoot) tr{font-size:.875rem}.\32 xl\:table-md :where(th,td){padding-block:.75rem;padding-inline:1rem}.\32 xl\:table-lg :not(thead,tfoot) tr{font-size:1.125rem}.\32 xl\:table-lg :where(th,td){padding-block:1rem;padding-inline:1.25rem}.\32 xl\:table-xl :not(thead,tfoot) tr{font-size:1.375rem}.\32 xl\:table-xl :where(th,td){padding-block:1.25rem;padding-inline:1.5rem}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:file-input{border:var(--border) solid #0000;cursor:pointer;appearance:none;background-color:var(--color-base-100);vertical-align:middle;-webkit-user-select:none;user-select:none;width:clamp(3rem,20rem,100%);height:var(--size);border-color:var(--input-color);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-field,.25rem) * 10);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));align-items:center;padding-inline-end:.75rem;font-size:.875rem;line-height:2;display:inline-flex}.sm\:file-input::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;height:calc(100% + var(--border) * 2);margin-inline-end:1rem;margin-block:calc(var(--border) * -1);color:var(--btn-fg);border-width:var(--border);border-style:solid;border-color:var(--btn-border);background-color:var(--btn-bg);background-size:calc(var(--noise) * 100%);background-image:var(--fx-noise);text-shadow:0 .5px oklch(1 0 0 / calc(var(--depth) * .15));box-shadow:0 .5px 0 .5px color-mix(in oklab, color-mix(in oklab, white 30%, var(--btn-bg)) calc(var(--depth) * 20%), #0000) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-border:color-mix(in oklab, var(--btn-bg), #000 5%);--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000);border-start-start-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-start-radius:calc(var(--join-es,var(--radius-field) - var(--border)));margin-inline-start:calc(var(--border) * -1);padding-inline:1rem;font-size:.875rem;font-weight:600}.sm\:file-input:focus{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) 10%, #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}}@layer daisyui.l1.l2{.sm\:file-input:has(>input[disabled]),.sm\:file-input:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}box-shadow:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);&::file-selector-button{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);--btn-border:#0000;--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000);background-image:none}}.sm\:file-input-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.sm\:file-input-ghost::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;border-start-end-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-end-radius:calc(var(--join-es,var(--radius-field) - var(--border)));height:100%;margin-block:0;margin-inline:0 1rem;padding-inline:1rem}.sm\:file-input-ghost:focus,.sm\:file-input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.sm\:file-input-neutral{--btn-color:var(--color-neutral)}.sm\:file-input-neutral::file-selector-button{color:var(--color-neutral-content)}.file-input-neutral,.sm\:file-input-neutral:focus,.sm\:file-input-neutral:focus-within{--input-color:var(--color-neutral)}.sm\:file-input-primary{--btn-color:var(--color-primary)}.sm\:file-input-primary::file-selector-button{color:var(--color-primary-content)}.file-input-primary,.sm\:file-input-primary:focus,.sm\:file-input-primary:focus-within{--input-color:var(--color-primary)}.sm\:file-input-secondary{--btn-color:var(--color-secondary)}.sm\:file-input-secondary::file-selector-button{color:var(--color-secondary-content)}.file-input-secondary,.sm\:file-input-secondary:focus,.sm\:file-input-secondary:focus-within{--input-color:var(--color-secondary)}.sm\:file-input-accent{--btn-color:var(--color-accent)}.sm\:file-input-accent::file-selector-button{color:var(--color-accent-content)}.file-input-accent,.sm\:file-input-accent:focus,.sm\:file-input-accent:focus-within{--input-color:var(--color-accent)}.sm\:file-input-info{--btn-color:var(--color-info)}.sm\:file-input-info::file-selector-button{color:var(--color-info-content)}.file-input-info,.sm\:file-input-info:focus,.sm\:file-input-info:focus-within{--input-color:var(--color-info)}.sm\:file-input-success{--btn-color:var(--color-success)}.sm\:file-input-success::file-selector-button{color:var(--color-success-content)}.file-input-success,.sm\:file-input-success:focus,.sm\:file-input-success:focus-within{--input-color:var(--color-success)}.sm\:file-input-warning{--btn-color:var(--color-warning)}.sm\:file-input-warning::file-selector-button{color:var(--color-warning-content)}.file-input-warning,.sm\:file-input-warning:focus,.sm\:file-input-warning:focus-within{--input-color:var(--color-warning)}.sm\:file-input-error{--btn-color:var(--color-error)}.sm\:file-input-error::file-selector-button{color:var(--color-error-content)}.file-input-error,.sm\:file-input-error:focus,.sm\:file-input-error:focus-within{--input-color:var(--color-error)}.sm\:file-input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem;line-height:1rem}.sm\:file-input-xs::file-selector-button{font-size:.6875rem}.sm\:file-input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem;line-height:1.5rem}.sm\:file-input-sm::file-selector-button{font-size:.75rem}.sm\:file-input-md{--size:calc(var(--size-field,.25rem) * 10);font-size:.875rem;line-height:2}.sm\:file-input-md::file-selector-button{font-size:.875rem}.sm\:file-input-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:1.125rem;line-height:2.5rem}.sm\:file-input-lg::file-selector-button{font-size:1.125rem}.sm\:file-input-xl{--size:calc(var(--size-field,.25rem) * 14);padding-inline-end:1.5rem;font-size:1.125rem;line-height:3rem}.sm\:file-input-xl::file-selector-button{font-size:1.375rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:file-input{border:var(--border) solid #0000;cursor:pointer;appearance:none;background-color:var(--color-base-100);vertical-align:middle;-webkit-user-select:none;user-select:none;width:clamp(3rem,20rem,100%);height:var(--size);border-color:var(--input-color);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-field,.25rem) * 10);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));align-items:center;padding-inline-end:.75rem;font-size:.875rem;line-height:2;display:inline-flex}.md\:file-input::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;height:calc(100% + var(--border) * 2);margin-inline-end:1rem;margin-block:calc(var(--border) * -1);color:var(--btn-fg);border-width:var(--border);border-style:solid;border-color:var(--btn-border);background-color:var(--btn-bg);background-size:calc(var(--noise) * 100%);background-image:var(--fx-noise);text-shadow:0 .5px oklch(1 0 0 / calc(var(--depth) * .15));box-shadow:0 .5px 0 .5px color-mix(in oklab, color-mix(in oklab, white 30%, var(--btn-bg)) calc(var(--depth) * 20%), #0000) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-border:color-mix(in oklab, var(--btn-bg), #000 5%);--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000);border-start-start-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-start-radius:calc(var(--join-es,var(--radius-field) - var(--border)));margin-inline-start:calc(var(--border) * -1);padding-inline:1rem;font-size:.875rem;font-weight:600}.md\:file-input:focus{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) 10%, #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}}@layer daisyui.l1.l2{.md\:file-input:has(>input[disabled]),.md\:file-input:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}box-shadow:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);&::file-selector-button{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);--btn-border:#0000;--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000);background-image:none}}.md\:file-input-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.md\:file-input-ghost::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;border-start-end-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-end-radius:calc(var(--join-es,var(--radius-field) - var(--border)));height:100%;margin-block:0;margin-inline:0 1rem;padding-inline:1rem}.md\:file-input-ghost:focus,.md\:file-input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.md\:file-input-neutral{--btn-color:var(--color-neutral)}.md\:file-input-neutral::file-selector-button{color:var(--color-neutral-content)}.file-input-neutral,.md\:file-input-neutral:focus,.md\:file-input-neutral:focus-within{--input-color:var(--color-neutral)}.md\:file-input-primary{--btn-color:var(--color-primary)}.md\:file-input-primary::file-selector-button{color:var(--color-primary-content)}.file-input-primary,.md\:file-input-primary:focus,.md\:file-input-primary:focus-within{--input-color:var(--color-primary)}.md\:file-input-secondary{--btn-color:var(--color-secondary)}.md\:file-input-secondary::file-selector-button{color:var(--color-secondary-content)}.file-input-secondary,.md\:file-input-secondary:focus,.md\:file-input-secondary:focus-within{--input-color:var(--color-secondary)}.md\:file-input-accent{--btn-color:var(--color-accent)}.md\:file-input-accent::file-selector-button{color:var(--color-accent-content)}.file-input-accent,.md\:file-input-accent:focus,.md\:file-input-accent:focus-within{--input-color:var(--color-accent)}.md\:file-input-info{--btn-color:var(--color-info)}.md\:file-input-info::file-selector-button{color:var(--color-info-content)}.file-input-info,.md\:file-input-info:focus,.md\:file-input-info:focus-within{--input-color:var(--color-info)}.md\:file-input-success{--btn-color:var(--color-success)}.md\:file-input-success::file-selector-button{color:var(--color-success-content)}.file-input-success,.md\:file-input-success:focus,.md\:file-input-success:focus-within{--input-color:var(--color-success)}.md\:file-input-warning{--btn-color:var(--color-warning)}.md\:file-input-warning::file-selector-button{color:var(--color-warning-content)}.file-input-warning,.md\:file-input-warning:focus,.md\:file-input-warning:focus-within{--input-color:var(--color-warning)}.md\:file-input-error{--btn-color:var(--color-error)}.md\:file-input-error::file-selector-button{color:var(--color-error-content)}.file-input-error,.md\:file-input-error:focus,.md\:file-input-error:focus-within{--input-color:var(--color-error)}.md\:file-input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem;line-height:1rem}.md\:file-input-xs::file-selector-button{font-size:.6875rem}.md\:file-input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem;line-height:1.5rem}.md\:file-input-sm::file-selector-button{font-size:.75rem}.md\:file-input-md{--size:calc(var(--size-field,.25rem) * 10);font-size:.875rem;line-height:2}.md\:file-input-md::file-selector-button{font-size:.875rem}.md\:file-input-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:1.125rem;line-height:2.5rem}.md\:file-input-lg::file-selector-button{font-size:1.125rem}.md\:file-input-xl{--size:calc(var(--size-field,.25rem) * 14);padding-inline-end:1.5rem;font-size:1.125rem;line-height:3rem}.md\:file-input-xl::file-selector-button{font-size:1.375rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:file-input{border:var(--border) solid #0000;cursor:pointer;appearance:none;background-color:var(--color-base-100);vertical-align:middle;-webkit-user-select:none;user-select:none;width:clamp(3rem,20rem,100%);height:var(--size);border-color:var(--input-color);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-field,.25rem) * 10);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));align-items:center;padding-inline-end:.75rem;font-size:.875rem;line-height:2;display:inline-flex}.lg\:file-input::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;height:calc(100% + var(--border) * 2);margin-inline-end:1rem;margin-block:calc(var(--border) * -1);color:var(--btn-fg);border-width:var(--border);border-style:solid;border-color:var(--btn-border);background-color:var(--btn-bg);background-size:calc(var(--noise) * 100%);background-image:var(--fx-noise);text-shadow:0 .5px oklch(1 0 0 / calc(var(--depth) * .15));box-shadow:0 .5px 0 .5px color-mix(in oklab, color-mix(in oklab, white 30%, var(--btn-bg)) calc(var(--depth) * 20%), #0000) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-border:color-mix(in oklab, var(--btn-bg), #000 5%);--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000);border-start-start-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-start-radius:calc(var(--join-es,var(--radius-field) - var(--border)));margin-inline-start:calc(var(--border) * -1);padding-inline:1rem;font-size:.875rem;font-weight:600}.lg\:file-input:focus{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) 10%, #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}}@layer daisyui.l1.l2{.lg\:file-input:has(>input[disabled]),.lg\:file-input:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}box-shadow:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);&::file-selector-button{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);--btn-border:#0000;--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000);background-image:none}}.lg\:file-input-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.lg\:file-input-ghost::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;border-start-end-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-end-radius:calc(var(--join-es,var(--radius-field) - var(--border)));height:100%;margin-block:0;margin-inline:0 1rem;padding-inline:1rem}.lg\:file-input-ghost:focus,.lg\:file-input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.lg\:file-input-neutral{--btn-color:var(--color-neutral)}.lg\:file-input-neutral::file-selector-button{color:var(--color-neutral-content)}.file-input-neutral,.lg\:file-input-neutral:focus,.lg\:file-input-neutral:focus-within{--input-color:var(--color-neutral)}.lg\:file-input-primary{--btn-color:var(--color-primary)}.lg\:file-input-primary::file-selector-button{color:var(--color-primary-content)}.file-input-primary,.lg\:file-input-primary:focus,.lg\:file-input-primary:focus-within{--input-color:var(--color-primary)}.lg\:file-input-secondary{--btn-color:var(--color-secondary)}.lg\:file-input-secondary::file-selector-button{color:var(--color-secondary-content)}.file-input-secondary,.lg\:file-input-secondary:focus,.lg\:file-input-secondary:focus-within{--input-color:var(--color-secondary)}.lg\:file-input-accent{--btn-color:var(--color-accent)}.lg\:file-input-accent::file-selector-button{color:var(--color-accent-content)}.file-input-accent,.lg\:file-input-accent:focus,.lg\:file-input-accent:focus-within{--input-color:var(--color-accent)}.lg\:file-input-info{--btn-color:var(--color-info)}.lg\:file-input-info::file-selector-button{color:var(--color-info-content)}.file-input-info,.lg\:file-input-info:focus,.lg\:file-input-info:focus-within{--input-color:var(--color-info)}.lg\:file-input-success{--btn-color:var(--color-success)}.lg\:file-input-success::file-selector-button{color:var(--color-success-content)}.file-input-success,.lg\:file-input-success:focus,.lg\:file-input-success:focus-within{--input-color:var(--color-success)}.lg\:file-input-warning{--btn-color:var(--color-warning)}.lg\:file-input-warning::file-selector-button{color:var(--color-warning-content)}.file-input-warning,.lg\:file-input-warning:focus,.lg\:file-input-warning:focus-within{--input-color:var(--color-warning)}.lg\:file-input-error{--btn-color:var(--color-error)}.lg\:file-input-error::file-selector-button{color:var(--color-error-content)}.file-input-error,.lg\:file-input-error:focus,.lg\:file-input-error:focus-within{--input-color:var(--color-error)}.lg\:file-input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem;line-height:1rem}.lg\:file-input-xs::file-selector-button{font-size:.6875rem}.lg\:file-input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem;line-height:1.5rem}.lg\:file-input-sm::file-selector-button{font-size:.75rem}.lg\:file-input-md{--size:calc(var(--size-field,.25rem) * 10);font-size:.875rem;line-height:2}.lg\:file-input-md::file-selector-button{font-size:.875rem}.lg\:file-input-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:1.125rem;line-height:2.5rem}.lg\:file-input-lg::file-selector-button{font-size:1.125rem}.lg\:file-input-xl{--size:calc(var(--size-field,.25rem) * 14);padding-inline-end:1.5rem;font-size:1.125rem;line-height:3rem}.lg\:file-input-xl::file-selector-button{font-size:1.375rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:file-input{border:var(--border) solid #0000;cursor:pointer;appearance:none;background-color:var(--color-base-100);vertical-align:middle;-webkit-user-select:none;user-select:none;width:clamp(3rem,20rem,100%);height:var(--size);border-color:var(--input-color);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-field,.25rem) * 10);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));align-items:center;padding-inline-end:.75rem;font-size:.875rem;line-height:2;display:inline-flex}.xl\:file-input::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;height:calc(100% + var(--border) * 2);margin-inline-end:1rem;margin-block:calc(var(--border) * -1);color:var(--btn-fg);border-width:var(--border);border-style:solid;border-color:var(--btn-border);background-color:var(--btn-bg);background-size:calc(var(--noise) * 100%);background-image:var(--fx-noise);text-shadow:0 .5px oklch(1 0 0 / calc(var(--depth) * .15));box-shadow:0 .5px 0 .5px color-mix(in oklab, color-mix(in oklab, white 30%, var(--btn-bg)) calc(var(--depth) * 20%), #0000) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-border:color-mix(in oklab, var(--btn-bg), #000 5%);--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000);border-start-start-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-start-radius:calc(var(--join-es,var(--radius-field) - var(--border)));margin-inline-start:calc(var(--border) * -1);padding-inline:1rem;font-size:.875rem;font-weight:600}.xl\:file-input:focus{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) 10%, #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}}@layer daisyui.l1.l2{.xl\:file-input:has(>input[disabled]),.xl\:file-input:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}box-shadow:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);&::file-selector-button{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);--btn-border:#0000;--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000);background-image:none}}.xl\:file-input-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.xl\:file-input-ghost::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;border-start-end-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-end-radius:calc(var(--join-es,var(--radius-field) - var(--border)));height:100%;margin-block:0;margin-inline:0 1rem;padding-inline:1rem}.xl\:file-input-ghost:focus,.xl\:file-input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.xl\:file-input-neutral{--btn-color:var(--color-neutral)}.xl\:file-input-neutral::file-selector-button{color:var(--color-neutral-content)}.file-input-neutral,.xl\:file-input-neutral:focus,.xl\:file-input-neutral:focus-within{--input-color:var(--color-neutral)}.xl\:file-input-primary{--btn-color:var(--color-primary)}.xl\:file-input-primary::file-selector-button{color:var(--color-primary-content)}.file-input-primary,.xl\:file-input-primary:focus,.xl\:file-input-primary:focus-within{--input-color:var(--color-primary)}.xl\:file-input-secondary{--btn-color:var(--color-secondary)}.xl\:file-input-secondary::file-selector-button{color:var(--color-secondary-content)}.file-input-secondary,.xl\:file-input-secondary:focus,.xl\:file-input-secondary:focus-within{--input-color:var(--color-secondary)}.xl\:file-input-accent{--btn-color:var(--color-accent)}.xl\:file-input-accent::file-selector-button{color:var(--color-accent-content)}.file-input-accent,.xl\:file-input-accent:focus,.xl\:file-input-accent:focus-within{--input-color:var(--color-accent)}.xl\:file-input-info{--btn-color:var(--color-info)}.xl\:file-input-info::file-selector-button{color:var(--color-info-content)}.file-input-info,.xl\:file-input-info:focus,.xl\:file-input-info:focus-within{--input-color:var(--color-info)}.xl\:file-input-success{--btn-color:var(--color-success)}.xl\:file-input-success::file-selector-button{color:var(--color-success-content)}.file-input-success,.xl\:file-input-success:focus,.xl\:file-input-success:focus-within{--input-color:var(--color-success)}.xl\:file-input-warning{--btn-color:var(--color-warning)}.xl\:file-input-warning::file-selector-button{color:var(--color-warning-content)}.file-input-warning,.xl\:file-input-warning:focus,.xl\:file-input-warning:focus-within{--input-color:var(--color-warning)}.xl\:file-input-error{--btn-color:var(--color-error)}.xl\:file-input-error::file-selector-button{color:var(--color-error-content)}.file-input-error,.xl\:file-input-error:focus,.xl\:file-input-error:focus-within{--input-color:var(--color-error)}.xl\:file-input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem;line-height:1rem}.xl\:file-input-xs::file-selector-button{font-size:.6875rem}.xl\:file-input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem;line-height:1.5rem}.xl\:file-input-sm::file-selector-button{font-size:.75rem}.xl\:file-input-md{--size:calc(var(--size-field,.25rem) * 10);font-size:.875rem;line-height:2}.xl\:file-input-md::file-selector-button{font-size:.875rem}.xl\:file-input-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:1.125rem;line-height:2.5rem}.xl\:file-input-lg::file-selector-button{font-size:1.125rem}.xl\:file-input-xl{--size:calc(var(--size-field,.25rem) * 14);padding-inline-end:1.5rem;font-size:1.125rem;line-height:3rem}.xl\:file-input-xl::file-selector-button{font-size:1.375rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:file-input{border:var(--border) solid #0000;cursor:pointer;appearance:none;background-color:var(--color-base-100);vertical-align:middle;-webkit-user-select:none;user-select:none;width:clamp(3rem,20rem,100%);height:var(--size);border-color:var(--input-color);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-field,.25rem) * 10);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));align-items:center;padding-inline-end:.75rem;font-size:.875rem;line-height:2;display:inline-flex}.\32 xl\:file-input::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;height:calc(100% + var(--border) * 2);margin-inline-end:1rem;margin-block:calc(var(--border) * -1);color:var(--btn-fg);border-width:var(--border);border-style:solid;border-color:var(--btn-border);background-color:var(--btn-bg);background-size:calc(var(--noise) * 100%);background-image:var(--fx-noise);text-shadow:0 .5px oklch(1 0 0 / calc(var(--depth) * .15));box-shadow:0 .5px 0 .5px color-mix(in oklab, color-mix(in oklab, white 30%, var(--btn-bg)) calc(var(--depth) * 20%), #0000) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-border:color-mix(in oklab, var(--btn-bg), #000 5%);--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) 30%, #0000);border-start-start-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-start-radius:calc(var(--join-es,var(--radius-field) - var(--border)));margin-inline-start:calc(var(--border) * -1);padding-inline:1rem;font-size:.875rem;font-weight:600}.\32 xl\:file-input:focus{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) 10%, #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}}@layer daisyui.l1.l2{.\32 xl\:file-input:has(>input[disabled]),.\32 xl\:file-input:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}box-shadow:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);&::file-selector-button{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);--btn-border:#0000;--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000);background-image:none}}.\32 xl\:file-input-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.\32 xl\:file-input-ghost::file-selector-button{cursor:pointer;-webkit-user-select:none;user-select:none;border-start-end-radius:calc(var(--join-ss,var(--radius-field) - var(--border)));border-end-end-radius:calc(var(--join-es,var(--radius-field) - var(--border)));height:100%;margin-block:0;margin-inline:0 1rem;padding-inline:1rem}.\32 xl\:file-input-ghost:focus,.\32 xl\:file-input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.\32 xl\:file-input-neutral{--btn-color:var(--color-neutral)}.\32 xl\:file-input-neutral::file-selector-button{color:var(--color-neutral-content)}.file-input-neutral,.\32 xl\:file-input-neutral:focus,.\32 xl\:file-input-neutral:focus-within{--input-color:var(--color-neutral)}.\32 xl\:file-input-primary{--btn-color:var(--color-primary)}.\32 xl\:file-input-primary::file-selector-button{color:var(--color-primary-content)}.file-input-primary,.\32 xl\:file-input-primary:focus,.\32 xl\:file-input-primary:focus-within{--input-color:var(--color-primary)}.\32 xl\:file-input-secondary{--btn-color:var(--color-secondary)}.\32 xl\:file-input-secondary::file-selector-button{color:var(--color-secondary-content)}.file-input-secondary,.\32 xl\:file-input-secondary:focus,.\32 xl\:file-input-secondary:focus-within{--input-color:var(--color-secondary)}.\32 xl\:file-input-accent{--btn-color:var(--color-accent)}.\32 xl\:file-input-accent::file-selector-button{color:var(--color-accent-content)}.file-input-accent,.\32 xl\:file-input-accent:focus,.\32 xl\:file-input-accent:focus-within{--input-color:var(--color-accent)}.\32 xl\:file-input-info{--btn-color:var(--color-info)}.\32 xl\:file-input-info::file-selector-button{color:var(--color-info-content)}.file-input-info,.\32 xl\:file-input-info:focus,.\32 xl\:file-input-info:focus-within{--input-color:var(--color-info)}.\32 xl\:file-input-success{--btn-color:var(--color-success)}.\32 xl\:file-input-success::file-selector-button{color:var(--color-success-content)}.file-input-success,.\32 xl\:file-input-success:focus,.\32 xl\:file-input-success:focus-within{--input-color:var(--color-success)}.\32 xl\:file-input-warning{--btn-color:var(--color-warning)}.\32 xl\:file-input-warning::file-selector-button{color:var(--color-warning-content)}.file-input-warning,.\32 xl\:file-input-warning:focus,.\32 xl\:file-input-warning:focus-within{--input-color:var(--color-warning)}.\32 xl\:file-input-error{--btn-color:var(--color-error)}.\32 xl\:file-input-error::file-selector-button{color:var(--color-error-content)}.file-input-error,.\32 xl\:file-input-error:focus,.\32 xl\:file-input-error:focus-within{--input-color:var(--color-error)}.\32 xl\:file-input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem;line-height:1rem}.\32 xl\:file-input-xs::file-selector-button{font-size:.6875rem}.\32 xl\:file-input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem;line-height:1.5rem}.\32 xl\:file-input-sm::file-selector-button{font-size:.75rem}.\32 xl\:file-input-md{--size:calc(var(--size-field,.25rem) * 10);font-size:.875rem;line-height:2}.\32 xl\:file-input-md::file-selector-button{font-size:.875rem}.\32 xl\:file-input-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:1.125rem;line-height:2.5rem}.\32 xl\:file-input-lg::file-selector-button{font-size:1.125rem}.\32 xl\:file-input-xl{--size:calc(var(--size-field,.25rem) * 14);padding-inline-end:1.5rem;font-size:1.125rem;line-height:3rem}.\32 xl\:file-input-xl::file-selector-button{font-size:1.375rem}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:carousel{scroll-snap-type:x mandatory;scrollbar-width:none;display:inline-flex;overflow-x:scroll}@media (prefers-reduced-motion:no-preference){.sm\:carousel{scroll-behavior:smooth}}.sm\:carousel::-webkit-scrollbar{display:none}.sm\:carousel-item{box-sizing:content-box;scroll-snap-align:start;flex:none;display:flex}}@layer daisyui.l1.l2{.sm\:carousel-vertical{scroll-snap-type:y mandatory;flex-direction:column;overflow-y:scroll}.sm\:carousel-horizontal{scroll-snap-type:x mandatory;flex-direction:row;overflow-x:scroll}.sm\:carousel-start .carousel-item{scroll-snap-align:start}.sm\:carousel-center .carousel-item{scroll-snap-align:center}.sm\:carousel-end .carousel-item{scroll-snap-align:end}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:carousel{scroll-snap-type:x mandatory;scrollbar-width:none;display:inline-flex;overflow-x:scroll}@media (prefers-reduced-motion:no-preference){.md\:carousel{scroll-behavior:smooth}}.md\:carousel::-webkit-scrollbar{display:none}.md\:carousel-item{box-sizing:content-box;scroll-snap-align:start;flex:none;display:flex}}@layer daisyui.l1.l2{.md\:carousel-vertical{scroll-snap-type:y mandatory;flex-direction:column;overflow-y:scroll}.md\:carousel-horizontal{scroll-snap-type:x mandatory;flex-direction:row;overflow-x:scroll}.md\:carousel-start .carousel-item{scroll-snap-align:start}.md\:carousel-center .carousel-item{scroll-snap-align:center}.md\:carousel-end .carousel-item{scroll-snap-align:end}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:carousel{scroll-snap-type:x mandatory;scrollbar-width:none;display:inline-flex;overflow-x:scroll}@media (prefers-reduced-motion:no-preference){.lg\:carousel{scroll-behavior:smooth}}.lg\:carousel::-webkit-scrollbar{display:none}.lg\:carousel-item{box-sizing:content-box;scroll-snap-align:start;flex:none;display:flex}}@layer daisyui.l1.l2{.lg\:carousel-vertical{scroll-snap-type:y mandatory;flex-direction:column;overflow-y:scroll}.lg\:carousel-horizontal{scroll-snap-type:x mandatory;flex-direction:row;overflow-x:scroll}.lg\:carousel-start .carousel-item{scroll-snap-align:start}.lg\:carousel-center .carousel-item{scroll-snap-align:center}.lg\:carousel-end .carousel-item{scroll-snap-align:end}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:carousel{scroll-snap-type:x mandatory;scrollbar-width:none;display:inline-flex;overflow-x:scroll}@media (prefers-reduced-motion:no-preference){.xl\:carousel{scroll-behavior:smooth}}.xl\:carousel::-webkit-scrollbar{display:none}.xl\:carousel-item{box-sizing:content-box;scroll-snap-align:start;flex:none;display:flex}}@layer daisyui.l1.l2{.xl\:carousel-vertical{scroll-snap-type:y mandatory;flex-direction:column;overflow-y:scroll}.xl\:carousel-horizontal{scroll-snap-type:x mandatory;flex-direction:row;overflow-x:scroll}.xl\:carousel-start .carousel-item{scroll-snap-align:start}.xl\:carousel-center .carousel-item{scroll-snap-align:center}.xl\:carousel-end .carousel-item{scroll-snap-align:end}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:carousel{scroll-snap-type:x mandatory;scrollbar-width:none;display:inline-flex;overflow-x:scroll}@media (prefers-reduced-motion:no-preference){.\32 xl\:carousel{scroll-behavior:smooth}}.\32 xl\:carousel::-webkit-scrollbar{display:none}.\32 xl\:carousel-item{box-sizing:content-box;scroll-snap-align:start;flex:none;display:flex}}@layer daisyui.l1.l2{.\32 xl\:carousel-vertical{scroll-snap-type:y mandatory;flex-direction:column;overflow-y:scroll}.\32 xl\:carousel-horizontal{scroll-snap-type:x mandatory;flex-direction:row;overflow-x:scroll}.\32 xl\:carousel-start .carousel-item{scroll-snap-align:start}.\32 xl\:carousel-center .carousel-item{scroll-snap-align:center}.\32 xl\:carousel-end .carousel-item{scroll-snap-align:end}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.sm\:steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}&>.step-icon,&:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}&:first-child:before{--tw-content:none;content:var(--tw-content)}&[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}}}@layer daisyui.l1.l2{.sm\:steps .step-neutral+.step-neutral:before,.sm\:steps .step-neutral:after,.sm\:steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.sm\:steps .step-primary+.step-primary:before,.sm\:steps .step-primary:after,.sm\:steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.sm\:steps .step-secondary+.step-secondary:before,.sm\:steps .step-secondary:after,.sm\:steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.sm\:steps .step-accent+.step-accent:before,.sm\:steps .step-accent:after,.sm\:steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.sm\:steps .step-info+.step-info:before,.sm\:steps .step-info:after,.sm\:steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.sm\:steps .step-success+.step-success:before,.sm\:steps .step-success:after,.sm\:steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.sm\:steps .step-warning+.step-warning:before,.sm\:steps .step-warning:after,.sm\:steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.sm\:steps .step-error+.step-error:before,.sm\:steps .step-error:after,.sm\:steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.sm\:steps-horizontal{grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.sm\:steps-horizontal .step{text-align:center;grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;margin-inline-start:-100%;translate:0}[dir=rtl] &:before{translate:0}}.sm\:steps-vertical{grid-auto-rows:1fr;grid-auto-flow:row}.sm\:steps-vertical .step{grid-template-rows:auto;grid-template-columns:40px 1fr;justify-items:start;gap:.5rem;min-height:4rem;display:grid;&:before{width:.5rem;height:100%;margin-inline-start:50%;translate:-50% -50%}[dir=rtl] &:before{translate:50% -50%}}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.md\:steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}&>.step-icon,&:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}&:first-child:before{--tw-content:none;content:var(--tw-content)}&[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}}}@layer daisyui.l1.l2{.md\:steps .step-neutral+.step-neutral:before,.md\:steps .step-neutral:after,.md\:steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.md\:steps .step-primary+.step-primary:before,.md\:steps .step-primary:after,.md\:steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.md\:steps .step-secondary+.step-secondary:before,.md\:steps .step-secondary:after,.md\:steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.md\:steps .step-accent+.step-accent:before,.md\:steps .step-accent:after,.md\:steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.md\:steps .step-info+.step-info:before,.md\:steps .step-info:after,.md\:steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.md\:steps .step-success+.step-success:before,.md\:steps .step-success:after,.md\:steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.md\:steps .step-warning+.step-warning:before,.md\:steps .step-warning:after,.md\:steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.md\:steps .step-error+.step-error:before,.md\:steps .step-error:after,.md\:steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.md\:steps-horizontal{grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.md\:steps-horizontal .step{text-align:center;grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;margin-inline-start:-100%;translate:0}[dir=rtl] &:before{translate:0}}.md\:steps-vertical{grid-auto-rows:1fr;grid-auto-flow:row}.md\:steps-vertical .step{grid-template-rows:auto;grid-template-columns:40px 1fr;justify-items:start;gap:.5rem;min-height:4rem;display:grid;&:before{width:.5rem;height:100%;margin-inline-start:50%;translate:-50% -50%}[dir=rtl] &:before{translate:50% -50%}}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.lg\:steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}&>.step-icon,&:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}&:first-child:before{--tw-content:none;content:var(--tw-content)}&[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}}}@layer daisyui.l1.l2{.lg\:steps .step-neutral+.step-neutral:before,.lg\:steps .step-neutral:after,.lg\:steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.lg\:steps .step-primary+.step-primary:before,.lg\:steps .step-primary:after,.lg\:steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.lg\:steps .step-secondary+.step-secondary:before,.lg\:steps .step-secondary:after,.lg\:steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.lg\:steps .step-accent+.step-accent:before,.lg\:steps .step-accent:after,.lg\:steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.lg\:steps .step-info+.step-info:before,.lg\:steps .step-info:after,.lg\:steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.lg\:steps .step-success+.step-success:before,.lg\:steps .step-success:after,.lg\:steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.lg\:steps .step-warning+.step-warning:before,.lg\:steps .step-warning:after,.lg\:steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.lg\:steps .step-error+.step-error:before,.lg\:steps .step-error:after,.lg\:steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.lg\:steps-horizontal{grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.lg\:steps-horizontal .step{text-align:center;grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;margin-inline-start:-100%;translate:0}[dir=rtl] &:before{translate:0}}.lg\:steps-vertical{grid-auto-rows:1fr;grid-auto-flow:row}.lg\:steps-vertical .step{grid-template-rows:auto;grid-template-columns:40px 1fr;justify-items:start;gap:.5rem;min-height:4rem;display:grid;&:before{width:.5rem;height:100%;margin-inline-start:50%;translate:-50% -50%}[dir=rtl] &:before{translate:50% -50%}}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.xl\:steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}&>.step-icon,&:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}&:first-child:before{--tw-content:none;content:var(--tw-content)}&[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}}}@layer daisyui.l1.l2{.xl\:steps .step-neutral+.step-neutral:before,.xl\:steps .step-neutral:after,.xl\:steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.xl\:steps .step-primary+.step-primary:before,.xl\:steps .step-primary:after,.xl\:steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.xl\:steps .step-secondary+.step-secondary:before,.xl\:steps .step-secondary:after,.xl\:steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.xl\:steps .step-accent+.step-accent:before,.xl\:steps .step-accent:after,.xl\:steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.xl\:steps .step-info+.step-info:before,.xl\:steps .step-info:after,.xl\:steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.xl\:steps .step-success+.step-success:before,.xl\:steps .step-success:after,.xl\:steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.xl\:steps .step-warning+.step-warning:before,.xl\:steps .step-warning:after,.xl\:steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.xl\:steps .step-error+.step-error:before,.xl\:steps .step-error:after,.xl\:steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.xl\:steps-horizontal{grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.xl\:steps-horizontal .step{text-align:center;grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;margin-inline-start:-100%;translate:0}[dir=rtl] &:before{translate:0}}.xl\:steps-vertical{grid-auto-rows:1fr;grid-auto-flow:row}.xl\:steps-vertical .step{grid-template-rows:auto;grid-template-columns:40px 1fr;justify-items:start;gap:.5rem;min-height:4rem;display:grid;&:before{width:.5rem;height:100%;margin-inline-start:50%;translate:-50% -50%}[dir=rtl] &:before{translate:50% -50%}}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.\32 xl\:steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}&>.step-icon,&:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}&:first-child:before{--tw-content:none;content:var(--tw-content)}&[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}}}@layer daisyui.l1.l2{.\32 xl\:steps .step-neutral+.step-neutral:before,.\32 xl\:steps .step-neutral:after,.\32 xl\:steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.\32 xl\:steps .step-primary+.step-primary:before,.\32 xl\:steps .step-primary:after,.\32 xl\:steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.\32 xl\:steps .step-secondary+.step-secondary:before,.\32 xl\:steps .step-secondary:after,.\32 xl\:steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.\32 xl\:steps .step-accent+.step-accent:before,.\32 xl\:steps .step-accent:after,.\32 xl\:steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.\32 xl\:steps .step-info+.step-info:before,.\32 xl\:steps .step-info:after,.\32 xl\:steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.\32 xl\:steps .step-success+.step-success:before,.\32 xl\:steps .step-success:after,.\32 xl\:steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.\32 xl\:steps .step-warning+.step-warning:before,.\32 xl\:steps .step-warning:after,.\32 xl\:steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.\32 xl\:steps .step-error+.step-error:before,.\32 xl\:steps .step-error:after,.\32 xl\:steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.\32 xl\:steps-horizontal{grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.\32 xl\:steps-horizontal .step{text-align:center;grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid;&:before{width:100%;height:.5rem;margin-inline-start:-100%;translate:0}[dir=rtl] &:before{translate:0}}.\32 xl\:steps-vertical{grid-auto-rows:1fr;grid-auto-flow:row}.\32 xl\:steps-vertical .step{grid-template-rows:auto;grid-template-columns:40px 1fr;justify-items:start;gap:.5rem;min-height:4rem;display:grid;&:before{width:.5rem;height:100%;margin-inline-start:50%;translate:-50% -50%}[dir=rtl] &:before{translate:50% -50%}}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:dropdown{position-area:var(--anchor-v,block-end) var(--anchor-h,span-inline-end);display:inline-block;position:relative}.sm\:dropdown>:not(:has(~[class*=dropdown-content])):focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.sm\:dropdown .dropdown-content{position:absolute}.sm\:dropdown.dropdown-close .dropdown-content,.sm\:dropdown:not(details,.dropdown-open,.dropdown-hover:hover,:focus-within) .dropdown-content,.sm\:dropdown.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible)~.dropdown-content{transform-origin:top;opacity:0;display:none;scale:.95}.sm\:dropdown[popover],.sm\:dropdown .dropdown-content{z-index:999;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:opacity,scale,display,overlay;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s dropdown}}@starting-style{.sm\:dropdown[popover],.sm\:dropdown .dropdown-content{opacity:0;scale:.95}}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within)>[tabindex]:first-child{pointer-events:none}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within) .dropdown-content,.sm\:dropdown:not(.dropdown-close).dropdown-hover:hover .dropdown-content{opacity:1;scale:1}.sm\:dropdown:is(details){overflow:revert-layer;& summary{&::-webkit-details-marker{display:none}}}.sm\:dropdown:where([popover]){background:0 0}.sm\:dropdown[popover]{color:inherit;position:fixed;@supports not (position-area:bottom){margin:auto;&.dropdown-close,&.dropdown-open:not(:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}&::backdrop{background-color:oklab(0% none none/.3)}}&.dropdown-close,&:not(.dropdown-open,:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}}}@layer daisyui.l1.l2{.sm\:dropdown-start{--anchor-h:span-inline-end}.sm\:dropdown-start :where(.dropdown-content){inset-inline-end:auto;translate:0;[dir=rtl] &{translate:0}}.sm\:dropdown-start.dropdown-left{--anchor-h:left;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.sm\:dropdown-start.dropdown-right{--anchor-h:right;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.sm\:dropdown-center{--anchor-h:center}.sm\:dropdown-center :where(.dropdown-content){inset-inline-end:50%;translate:50%;[dir=rtl] &{translate:-50%}}.sm\:dropdown-center.dropdown-left{--anchor-h:left;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.sm\:dropdown-center.dropdown-right{--anchor-h:right;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.sm\:dropdown-end{--anchor-h:span-inline-start}.sm\:dropdown-end :where(.dropdown-content){inset-inline-end:0;translate:0;[dir=rtl] &{translate:0}}.sm\:dropdown-end.dropdown-left{--anchor-h:left;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.sm\:dropdown-end.dropdown-right{--anchor-h:right;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.sm\:dropdown-left{--anchor-h:left;--anchor-v:span-bottom}.sm\:dropdown-left .dropdown-content{inset-inline-end:100%;transform-origin:100%;top:0;bottom:auto}.sm\:dropdown-right{--anchor-h:right;--anchor-v:span-bottom}.sm\:dropdown-right .dropdown-content{inset-inline-start:100%;transform-origin:0;top:0;bottom:auto}.sm\:dropdown-bottom{--anchor-v:block-end}.sm\:dropdown-bottom .dropdown-content{transform-origin:top;top:100%;bottom:auto}.sm\:dropdown-top{--anchor-v:block-start}.sm\:dropdown-top .dropdown-content{transform-origin:bottom;top:auto;bottom:100%}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:dropdown{position-area:var(--anchor-v,block-end) var(--anchor-h,span-inline-end);display:inline-block;position:relative}.md\:dropdown>:not(:has(~[class*=dropdown-content])):focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.md\:dropdown .dropdown-content{position:absolute}.md\:dropdown.dropdown-close .dropdown-content,.md\:dropdown:not(details,.dropdown-open,.dropdown-hover:hover,:focus-within) .dropdown-content,.md\:dropdown.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible)~.dropdown-content{transform-origin:top;opacity:0;display:none;scale:.95}.md\:dropdown[popover],.md\:dropdown .dropdown-content{z-index:999;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:opacity,scale,display,overlay;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s dropdown}}@starting-style{.md\:dropdown[popover],.md\:dropdown .dropdown-content{opacity:0;scale:.95}}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within)>[tabindex]:first-child{pointer-events:none}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within) .dropdown-content,.md\:dropdown:not(.dropdown-close).dropdown-hover:hover .dropdown-content{opacity:1;scale:1}.md\:dropdown:is(details){overflow:revert-layer;& summary{&::-webkit-details-marker{display:none}}}.md\:dropdown:where([popover]){background:0 0}.md\:dropdown[popover]{color:inherit;position:fixed;@supports not (position-area:bottom){margin:auto;&.dropdown-close,&.dropdown-open:not(:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}&::backdrop{background-color:oklab(0% none none/.3)}}&.dropdown-close,&:not(.dropdown-open,:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}}}@layer daisyui.l1.l2{.md\:dropdown-start{--anchor-h:span-inline-end}.md\:dropdown-start :where(.dropdown-content){inset-inline-end:auto;translate:0;[dir=rtl] &{translate:0}}.md\:dropdown-start.dropdown-left{--anchor-h:left;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.md\:dropdown-start.dropdown-right{--anchor-h:right;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.md\:dropdown-center{--anchor-h:center}.md\:dropdown-center :where(.dropdown-content){inset-inline-end:50%;translate:50%;[dir=rtl] &{translate:-50%}}.md\:dropdown-center.dropdown-left{--anchor-h:left;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.md\:dropdown-center.dropdown-right{--anchor-h:right;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.md\:dropdown-end{--anchor-h:span-inline-start}.md\:dropdown-end :where(.dropdown-content){inset-inline-end:0;translate:0;[dir=rtl] &{translate:0}}.md\:dropdown-end.dropdown-left{--anchor-h:left;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.md\:dropdown-end.dropdown-right{--anchor-h:right;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.md\:dropdown-left{--anchor-h:left;--anchor-v:span-bottom}.md\:dropdown-left .dropdown-content{inset-inline-end:100%;transform-origin:100%;top:0;bottom:auto}.md\:dropdown-right{--anchor-h:right;--anchor-v:span-bottom}.md\:dropdown-right .dropdown-content{inset-inline-start:100%;transform-origin:0;top:0;bottom:auto}.md\:dropdown-bottom{--anchor-v:block-end}.md\:dropdown-bottom .dropdown-content{transform-origin:top;top:100%;bottom:auto}.md\:dropdown-top{--anchor-v:block-start}.md\:dropdown-top .dropdown-content{transform-origin:bottom;top:auto;bottom:100%}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:dropdown{position-area:var(--anchor-v,block-end) var(--anchor-h,span-inline-end);display:inline-block;position:relative}.lg\:dropdown>:not(:has(~[class*=dropdown-content])):focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.lg\:dropdown .dropdown-content{position:absolute}.lg\:dropdown.dropdown-close .dropdown-content,.lg\:dropdown:not(details,.dropdown-open,.dropdown-hover:hover,:focus-within) .dropdown-content,.lg\:dropdown.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible)~.dropdown-content{transform-origin:top;opacity:0;display:none;scale:.95}.lg\:dropdown[popover],.lg\:dropdown .dropdown-content{z-index:999;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:opacity,scale,display,overlay;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s dropdown}}@starting-style{.lg\:dropdown[popover],.lg\:dropdown .dropdown-content{opacity:0;scale:.95}}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within)>[tabindex]:first-child{pointer-events:none}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within) .dropdown-content,.lg\:dropdown:not(.dropdown-close).dropdown-hover:hover .dropdown-content{opacity:1;scale:1}.lg\:dropdown:is(details){overflow:revert-layer;& summary{&::-webkit-details-marker{display:none}}}.lg\:dropdown:where([popover]){background:0 0}.lg\:dropdown[popover]{color:inherit;position:fixed;@supports not (position-area:bottom){margin:auto;&.dropdown-close,&.dropdown-open:not(:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}&::backdrop{background-color:oklab(0% none none/.3)}}&.dropdown-close,&:not(.dropdown-open,:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}}}@layer daisyui.l1.l2{.lg\:dropdown-start{--anchor-h:span-inline-end}.lg\:dropdown-start :where(.dropdown-content){inset-inline-end:auto;translate:0;[dir=rtl] &{translate:0}}.lg\:dropdown-start.dropdown-left{--anchor-h:left;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.lg\:dropdown-start.dropdown-right{--anchor-h:right;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.lg\:dropdown-center{--anchor-h:center}.lg\:dropdown-center :where(.dropdown-content){inset-inline-end:50%;translate:50%;[dir=rtl] &{translate:-50%}}.lg\:dropdown-center.dropdown-left{--anchor-h:left;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.lg\:dropdown-center.dropdown-right{--anchor-h:right;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.lg\:dropdown-end{--anchor-h:span-inline-start}.lg\:dropdown-end :where(.dropdown-content){inset-inline-end:0;translate:0;[dir=rtl] &{translate:0}}.lg\:dropdown-end.dropdown-left{--anchor-h:left;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.lg\:dropdown-end.dropdown-right{--anchor-h:right;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.lg\:dropdown-left{--anchor-h:left;--anchor-v:span-bottom}.lg\:dropdown-left .dropdown-content{inset-inline-end:100%;transform-origin:100%;top:0;bottom:auto}.lg\:dropdown-right{--anchor-h:right;--anchor-v:span-bottom}.lg\:dropdown-right .dropdown-content{inset-inline-start:100%;transform-origin:0;top:0;bottom:auto}.lg\:dropdown-bottom{--anchor-v:block-end}.lg\:dropdown-bottom .dropdown-content{transform-origin:top;top:100%;bottom:auto}.lg\:dropdown-top{--anchor-v:block-start}.lg\:dropdown-top .dropdown-content{transform-origin:bottom;top:auto;bottom:100%}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:dropdown{position-area:var(--anchor-v,block-end) var(--anchor-h,span-inline-end);display:inline-block;position:relative}.xl\:dropdown>:not(:has(~[class*=dropdown-content])):focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.xl\:dropdown .dropdown-content{position:absolute}.xl\:dropdown.dropdown-close .dropdown-content,.xl\:dropdown:not(details,.dropdown-open,.dropdown-hover:hover,:focus-within) .dropdown-content,.xl\:dropdown.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible)~.dropdown-content{transform-origin:top;opacity:0;display:none;scale:.95}.xl\:dropdown[popover],.xl\:dropdown .dropdown-content{z-index:999;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:opacity,scale,display,overlay;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s dropdown}}@starting-style{.xl\:dropdown[popover],.xl\:dropdown .dropdown-content{opacity:0;scale:.95}}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within)>[tabindex]:first-child{pointer-events:none}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within) .dropdown-content,.xl\:dropdown:not(.dropdown-close).dropdown-hover:hover .dropdown-content{opacity:1;scale:1}.xl\:dropdown:is(details){overflow:revert-layer;& summary{&::-webkit-details-marker{display:none}}}.xl\:dropdown:where([popover]){background:0 0}.xl\:dropdown[popover]{color:inherit;position:fixed;@supports not (position-area:bottom){margin:auto;&.dropdown-close,&.dropdown-open:not(:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}&::backdrop{background-color:oklab(0% none none/.3)}}&.dropdown-close,&:not(.dropdown-open,:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}}}@layer daisyui.l1.l2{.xl\:dropdown-start{--anchor-h:span-inline-end}.xl\:dropdown-start :where(.dropdown-content){inset-inline-end:auto;translate:0;[dir=rtl] &{translate:0}}.xl\:dropdown-start.dropdown-left{--anchor-h:left;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.xl\:dropdown-start.dropdown-right{--anchor-h:right;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.xl\:dropdown-center{--anchor-h:center}.xl\:dropdown-center :where(.dropdown-content){inset-inline-end:50%;translate:50%;[dir=rtl] &{translate:-50%}}.xl\:dropdown-center.dropdown-left{--anchor-h:left;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.xl\:dropdown-center.dropdown-right{--anchor-h:right;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.xl\:dropdown-end{--anchor-h:span-inline-start}.xl\:dropdown-end :where(.dropdown-content){inset-inline-end:0;translate:0;[dir=rtl] &{translate:0}}.xl\:dropdown-end.dropdown-left{--anchor-h:left;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.xl\:dropdown-end.dropdown-right{--anchor-h:right;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.xl\:dropdown-left{--anchor-h:left;--anchor-v:span-bottom}.xl\:dropdown-left .dropdown-content{inset-inline-end:100%;transform-origin:100%;top:0;bottom:auto}.xl\:dropdown-right{--anchor-h:right;--anchor-v:span-bottom}.xl\:dropdown-right .dropdown-content{inset-inline-start:100%;transform-origin:0;top:0;bottom:auto}.xl\:dropdown-bottom{--anchor-v:block-end}.xl\:dropdown-bottom .dropdown-content{transform-origin:top;top:100%;bottom:auto}.xl\:dropdown-top{--anchor-v:block-start}.xl\:dropdown-top .dropdown-content{transform-origin:bottom;top:auto;bottom:100%}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:dropdown{position-area:var(--anchor-v,block-end) var(--anchor-h,span-inline-end);display:inline-block;position:relative}.\32 xl\:dropdown>:not(:has(~[class*=dropdown-content])):focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.\32 xl\:dropdown .dropdown-content{position:absolute}.\32 xl\:dropdown.dropdown-close .dropdown-content,.\32 xl\:dropdown:not(details,.dropdown-open,.dropdown-hover:hover,:focus-within) .dropdown-content,.\32 xl\:dropdown.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible)~.dropdown-content{transform-origin:top;opacity:0;display:none;scale:.95}.\32 xl\:dropdown[popover],.\32 xl\:dropdown .dropdown-content{z-index:999;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:opacity,scale,display,overlay;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s dropdown}}@starting-style{.\32 xl\:dropdown[popover],.\32 xl\:dropdown .dropdown-content{opacity:0;scale:.95}}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within)>[tabindex]:first-child{pointer-events:none}:is(.dropdown:not(.dropdown-close).dropdown-open,.dropdown:not(.dropdown-close):not(.dropdown-hover):focus,.dropdown:not(.dropdown-close):focus-within) .dropdown-content,.\32 xl\:dropdown:not(.dropdown-close).dropdown-hover:hover .dropdown-content{opacity:1;scale:1}.\32 xl\:dropdown:is(details){overflow:revert-layer;& summary{&::-webkit-details-marker{display:none}}}.\32 xl\:dropdown:where([popover]){background:0 0}.\32 xl\:dropdown[popover]{color:inherit;position:fixed;@supports not (position-area:bottom){margin:auto;&.dropdown-close,&.dropdown-open:not(:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}&::backdrop{background-color:oklab(0% none none/.3)}}&.dropdown-close,&:not(.dropdown-open,:popover-open){transform-origin:top;opacity:0;display:none;scale:.95}}}@layer daisyui.l1.l2{.\32 xl\:dropdown-start{--anchor-h:span-inline-end}.\32 xl\:dropdown-start :where(.dropdown-content){inset-inline-end:auto;translate:0;[dir=rtl] &{translate:0}}.\32 xl\:dropdown-start.dropdown-left{--anchor-h:left;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.\32 xl\:dropdown-start.dropdown-right{--anchor-h:right;--anchor-v:span-bottom;& .dropdown-content{top:0;bottom:auto}}.\32 xl\:dropdown-center{--anchor-h:center}.\32 xl\:dropdown-center :where(.dropdown-content){inset-inline-end:50%;translate:50%;[dir=rtl] &{translate:-50%}}.\32 xl\:dropdown-center.dropdown-left{--anchor-h:left;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.\32 xl\:dropdown-center.dropdown-right{--anchor-h:right;--anchor-v:center;& .dropdown-content{top:auto;bottom:50%;translate:0 50%}}.\32 xl\:dropdown-end{--anchor-h:span-inline-start}.\32 xl\:dropdown-end :where(.dropdown-content){inset-inline-end:0;translate:0;[dir=rtl] &{translate:0}}.\32 xl\:dropdown-end.dropdown-left{--anchor-h:left;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.\32 xl\:dropdown-end.dropdown-right{--anchor-h:right;--anchor-v:span-top;& .dropdown-content{top:auto;bottom:0}}.\32 xl\:dropdown-left{--anchor-h:left;--anchor-v:span-bottom}.\32 xl\:dropdown-left .dropdown-content{inset-inline-end:100%;transform-origin:100%;top:0;bottom:auto}.\32 xl\:dropdown-right{--anchor-h:right;--anchor-v:span-bottom}.\32 xl\:dropdown-right .dropdown-content{inset-inline-start:100%;transform-origin:0;top:0;bottom:auto}.\32 xl\:dropdown-bottom{--anchor-v:block-end}.\32 xl\:dropdown-bottom .dropdown-content{transform-origin:top;top:100%;bottom:auto}.\32 xl\:dropdown-top{--anchor-v:block-start}.\32 xl\:dropdown-top .dropdown-content{transform-origin:bottom;top:auto;bottom:100%}}}@keyframes dropdown{0%{opacity:0}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.sm\:navbar-start{justify-content:flex-start;align-items:center;width:50%;display:inline-flex}.sm\:navbar-center{flex-shrink:0;align-items:center;display:inline-flex}.sm\:navbar-end{justify-content:flex-end;align-items:center;width:50%;display:inline-flex}}@layer daisyui.l1.l2{:where(.navbar){position:relative}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.md\:navbar-start{justify-content:flex-start;align-items:center;width:50%;display:inline-flex}.md\:navbar-center{flex-shrink:0;align-items:center;display:inline-flex}.md\:navbar-end{justify-content:flex-end;align-items:center;width:50%;display:inline-flex}}@layer daisyui.l1.l2{:where(.navbar){position:relative}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.lg\:navbar-start{justify-content:flex-start;align-items:center;width:50%;display:inline-flex}.lg\:navbar-center{flex-shrink:0;align-items:center;display:inline-flex}.lg\:navbar-end{justify-content:flex-end;align-items:center;width:50%;display:inline-flex}}@layer daisyui.l1.l2{:where(.navbar){position:relative}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.xl\:navbar-start{justify-content:flex-start;align-items:center;width:50%;display:inline-flex}.xl\:navbar-center{flex-shrink:0;align-items:center;display:inline-flex}.xl\:navbar-end{justify-content:flex-end;align-items:center;width:50%;display:inline-flex}}@layer daisyui.l1.l2{:where(.navbar){position:relative}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.\32 xl\:navbar-start{justify-content:flex-start;align-items:center;width:50%;display:inline-flex}.\32 xl\:navbar-center{flex-shrink:0;align-items:center;display:inline-flex}.\32 xl\:navbar-end{justify-content:flex-end;align-items:center;width:50%;display:inline-flex}}@layer daisyui.l1.l2{:where(.navbar){position:relative}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:toast{inset-inline:auto 1rem;translate:var(--toast-x,0) var(--toast-y,0);background-color:#0000;flex-direction:column;gap:.5rem;width:max-content;max-width:calc(100vw - 2rem);display:flex;position:fixed;top:auto;bottom:1rem}@media (prefers-reduced-motion:no-preference){.sm\:toast>*{animation:.25s ease-out toast}}}@layer daisyui.l1.l2{.sm\:toast-start{--toast-x:0;inset-inline:1rem auto}.sm\:toast-center{--toast-x:-50%;inset-inline:50%}.sm\:toast-center:dir(rtl){--toast-x:50%}.sm\:toast-end{--toast-x:0;inset-inline:auto 1rem}.sm\:toast-bottom{--toast-y:0;top:auto;bottom:1rem}.sm\:toast-middle{--toast-y:-50%;top:50%;bottom:auto}.sm\:toast-top{--toast-y:0;top:1rem;bottom:auto}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:toast{inset-inline:auto 1rem;translate:var(--toast-x,0) var(--toast-y,0);background-color:#0000;flex-direction:column;gap:.5rem;width:max-content;max-width:calc(100vw - 2rem);display:flex;position:fixed;top:auto;bottom:1rem}@media (prefers-reduced-motion:no-preference){.md\:toast>*{animation:.25s ease-out toast}}}@layer daisyui.l1.l2{.md\:toast-start{--toast-x:0;inset-inline:1rem auto}.md\:toast-center{--toast-x:-50%;inset-inline:50%}.md\:toast-center:dir(rtl){--toast-x:50%}.md\:toast-end{--toast-x:0;inset-inline:auto 1rem}.md\:toast-bottom{--toast-y:0;top:auto;bottom:1rem}.md\:toast-middle{--toast-y:-50%;top:50%;bottom:auto}.md\:toast-top{--toast-y:0;top:1rem;bottom:auto}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:toast{inset-inline:auto 1rem;translate:var(--toast-x,0) var(--toast-y,0);background-color:#0000;flex-direction:column;gap:.5rem;width:max-content;max-width:calc(100vw - 2rem);display:flex;position:fixed;top:auto;bottom:1rem}@media (prefers-reduced-motion:no-preference){.lg\:toast>*{animation:.25s ease-out toast}}}@layer daisyui.l1.l2{.lg\:toast-start{--toast-x:0;inset-inline:1rem auto}.lg\:toast-center{--toast-x:-50%;inset-inline:50%}.lg\:toast-center:dir(rtl){--toast-x:50%}.lg\:toast-end{--toast-x:0;inset-inline:auto 1rem}.lg\:toast-bottom{--toast-y:0;top:auto;bottom:1rem}.lg\:toast-middle{--toast-y:-50%;top:50%;bottom:auto}.lg\:toast-top{--toast-y:0;top:1rem;bottom:auto}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:toast{inset-inline:auto 1rem;translate:var(--toast-x,0) var(--toast-y,0);background-color:#0000;flex-direction:column;gap:.5rem;width:max-content;max-width:calc(100vw - 2rem);display:flex;position:fixed;top:auto;bottom:1rem}@media (prefers-reduced-motion:no-preference){.xl\:toast>*{animation:.25s ease-out toast}}}@layer daisyui.l1.l2{.xl\:toast-start{--toast-x:0;inset-inline:1rem auto}.xl\:toast-center{--toast-x:-50%;inset-inline:50%}.xl\:toast-center:dir(rtl){--toast-x:50%}.xl\:toast-end{--toast-x:0;inset-inline:auto 1rem}.xl\:toast-bottom{--toast-y:0;top:auto;bottom:1rem}.xl\:toast-middle{--toast-y:-50%;top:50%;bottom:auto}.xl\:toast-top{--toast-y:0;top:1rem;bottom:auto}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:toast{inset-inline:auto 1rem;translate:var(--toast-x,0) var(--toast-y,0);background-color:#0000;flex-direction:column;gap:.5rem;width:max-content;max-width:calc(100vw - 2rem);display:flex;position:fixed;top:auto;bottom:1rem}@media (prefers-reduced-motion:no-preference){.\32 xl\:toast>*{animation:.25s ease-out toast}}}@layer daisyui.l1.l2{.\32 xl\:toast-start{--toast-x:0;inset-inline:1rem auto}.\32 xl\:toast-center{--toast-x:-50%;inset-inline:50%}.\32 xl\:toast-center:dir(rtl){--toast-x:50%}.\32 xl\:toast-end{--toast-x:0;inset-inline:auto 1rem}.\32 xl\:toast-bottom{--toast-y:0;top:auto;bottom:1rem}.\32 xl\:toast-middle{--toast-y:-50%;top:50%;bottom:auto}.\32 xl\:toast-top{--toast-y:0;top:1rem;bottom:auto}}}@keyframes toast{0%{opacity:0;scale:.9}to{opacity:1;scale:1}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:range{-webkit-appearance:none;appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:color-mix(in oklab, currentColor 10%, #0000);--range-fill-x:calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2));--range-fill-y:0;--range-fill-spread:calc(100cqw * var(--range-fill));cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden;container-type:inline-size}[dir=rtl] .range{--range-dir:-1}.sm\:range:focus{outline:none}.sm\:range:focus-visible{outline-offset:2px;outline:2px solid}.sm\:range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.sm\:range::-webkit-slider-runnable-track{border:1px solid}}.sm\:range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;-webkit-appearance:none;appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);position:relative;inset-block-start:50%;transform:translateY(-50%)}.sm\:range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.sm\:range::-moz-range-track{border:1px solid}}.sm\:range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);background-color:currentColor;position:relative}.sm\:range:disabled{cursor:not-allowed;opacity:.3}}@layer daisyui.l1.l2{.sm\:range-primary{color:var(--color-primary);--range-thumb:var(--color-primary-content)}.sm\:range-secondary{color:var(--color-secondary);--range-thumb:var(--color-secondary-content)}.sm\:range-accent{color:var(--color-accent);--range-thumb:var(--color-accent-content)}.sm\:range-neutral{color:var(--color-neutral);--range-thumb:var(--color-neutral-content)}.sm\:range-success{color:var(--color-success);--range-thumb:var(--color-success-content)}.sm\:range-warning{color:var(--color-warning);--range-thumb:var(--color-warning-content)}.sm\:range-info{color:var(--color-info);--range-thumb:var(--color-info-content)}.sm\:range-error{color:var(--color-error);--range-thumb:var(--color-error-content)}.sm\:range-xs{--range-thumb-size:calc(var(--size-selector,.25rem) * 4)}.sm\:range-sm{--range-thumb-size:calc(var(--size-selector,.25rem) * 5)}.sm\:range-md{--range-thumb-size:calc(var(--size-selector,.25rem) * 6)}.sm\:range-lg{--range-thumb-size:calc(var(--size-selector,.25rem) * 7)}.sm\:range-xl{--range-thumb-size:calc(var(--size-selector,.25rem) * 8)}.sm\:range-vertical{writing-mode:vertical-lr;width:var(--range-thumb-size);--range-fill-x:0;--range-fill-y:calc(100cqh + var(--range-thumb-size) / 2);--range-fill-spread:calc(100cqh * var(--range-fill));direction:rtl;height:clamp(3rem,20rem,100%)}.sm\:range-vertical::-webkit-slider-runnable-track{height:100%;width:calc(var(--range-thumb-size) * .5)}.sm\:range-vertical::-webkit-slider-thumb{transform:translate(-50%)}.sm\:range-vertical::-moz-range-track{height:100%;width:calc(var(--range-thumb-size) * .5)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:range{-webkit-appearance:none;appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:color-mix(in oklab, currentColor 10%, #0000);--range-fill-x:calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2));--range-fill-y:0;--range-fill-spread:calc(100cqw * var(--range-fill));cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden;container-type:inline-size}[dir=rtl] .range{--range-dir:-1}.md\:range:focus{outline:none}.md\:range:focus-visible{outline-offset:2px;outline:2px solid}.md\:range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.md\:range::-webkit-slider-runnable-track{border:1px solid}}.md\:range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;-webkit-appearance:none;appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);position:relative;inset-block-start:50%;transform:translateY(-50%)}.md\:range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.md\:range::-moz-range-track{border:1px solid}}.md\:range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);background-color:currentColor;position:relative}.md\:range:disabled{cursor:not-allowed;opacity:.3}}@layer daisyui.l1.l2{.md\:range-primary{color:var(--color-primary);--range-thumb:var(--color-primary-content)}.md\:range-secondary{color:var(--color-secondary);--range-thumb:var(--color-secondary-content)}.md\:range-accent{color:var(--color-accent);--range-thumb:var(--color-accent-content)}.md\:range-neutral{color:var(--color-neutral);--range-thumb:var(--color-neutral-content)}.md\:range-success{color:var(--color-success);--range-thumb:var(--color-success-content)}.md\:range-warning{color:var(--color-warning);--range-thumb:var(--color-warning-content)}.md\:range-info{color:var(--color-info);--range-thumb:var(--color-info-content)}.md\:range-error{color:var(--color-error);--range-thumb:var(--color-error-content)}.md\:range-xs{--range-thumb-size:calc(var(--size-selector,.25rem) * 4)}.md\:range-sm{--range-thumb-size:calc(var(--size-selector,.25rem) * 5)}.md\:range-md{--range-thumb-size:calc(var(--size-selector,.25rem) * 6)}.md\:range-lg{--range-thumb-size:calc(var(--size-selector,.25rem) * 7)}.md\:range-xl{--range-thumb-size:calc(var(--size-selector,.25rem) * 8)}.md\:range-vertical{writing-mode:vertical-lr;width:var(--range-thumb-size);--range-fill-x:0;--range-fill-y:calc(100cqh + var(--range-thumb-size) / 2);--range-fill-spread:calc(100cqh * var(--range-fill));direction:rtl;height:clamp(3rem,20rem,100%)}.md\:range-vertical::-webkit-slider-runnable-track{height:100%;width:calc(var(--range-thumb-size) * .5)}.md\:range-vertical::-webkit-slider-thumb{transform:translate(-50%)}.md\:range-vertical::-moz-range-track{height:100%;width:calc(var(--range-thumb-size) * .5)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:range{-webkit-appearance:none;appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:color-mix(in oklab, currentColor 10%, #0000);--range-fill-x:calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2));--range-fill-y:0;--range-fill-spread:calc(100cqw * var(--range-fill));cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden;container-type:inline-size}[dir=rtl] .range{--range-dir:-1}.lg\:range:focus{outline:none}.lg\:range:focus-visible{outline-offset:2px;outline:2px solid}.lg\:range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.lg\:range::-webkit-slider-runnable-track{border:1px solid}}.lg\:range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;-webkit-appearance:none;appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);position:relative;inset-block-start:50%;transform:translateY(-50%)}.lg\:range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.lg\:range::-moz-range-track{border:1px solid}}.lg\:range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);background-color:currentColor;position:relative}.lg\:range:disabled{cursor:not-allowed;opacity:.3}}@layer daisyui.l1.l2{.lg\:range-primary{color:var(--color-primary);--range-thumb:var(--color-primary-content)}.lg\:range-secondary{color:var(--color-secondary);--range-thumb:var(--color-secondary-content)}.lg\:range-accent{color:var(--color-accent);--range-thumb:var(--color-accent-content)}.lg\:range-neutral{color:var(--color-neutral);--range-thumb:var(--color-neutral-content)}.lg\:range-success{color:var(--color-success);--range-thumb:var(--color-success-content)}.lg\:range-warning{color:var(--color-warning);--range-thumb:var(--color-warning-content)}.lg\:range-info{color:var(--color-info);--range-thumb:var(--color-info-content)}.lg\:range-error{color:var(--color-error);--range-thumb:var(--color-error-content)}.lg\:range-xs{--range-thumb-size:calc(var(--size-selector,.25rem) * 4)}.lg\:range-sm{--range-thumb-size:calc(var(--size-selector,.25rem) * 5)}.lg\:range-md{--range-thumb-size:calc(var(--size-selector,.25rem) * 6)}.lg\:range-lg{--range-thumb-size:calc(var(--size-selector,.25rem) * 7)}.lg\:range-xl{--range-thumb-size:calc(var(--size-selector,.25rem) * 8)}.lg\:range-vertical{writing-mode:vertical-lr;width:var(--range-thumb-size);--range-fill-x:0;--range-fill-y:calc(100cqh + var(--range-thumb-size) / 2);--range-fill-spread:calc(100cqh * var(--range-fill));direction:rtl;height:clamp(3rem,20rem,100%)}.lg\:range-vertical::-webkit-slider-runnable-track{height:100%;width:calc(var(--range-thumb-size) * .5)}.lg\:range-vertical::-webkit-slider-thumb{transform:translate(-50%)}.lg\:range-vertical::-moz-range-track{height:100%;width:calc(var(--range-thumb-size) * .5)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:range{-webkit-appearance:none;appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:color-mix(in oklab, currentColor 10%, #0000);--range-fill-x:calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2));--range-fill-y:0;--range-fill-spread:calc(100cqw * var(--range-fill));cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden;container-type:inline-size}[dir=rtl] .range{--range-dir:-1}.xl\:range:focus{outline:none}.xl\:range:focus-visible{outline-offset:2px;outline:2px solid}.xl\:range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.xl\:range::-webkit-slider-runnable-track{border:1px solid}}.xl\:range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;-webkit-appearance:none;appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);position:relative;inset-block-start:50%;transform:translateY(-50%)}.xl\:range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.xl\:range::-moz-range-track{border:1px solid}}.xl\:range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);background-color:currentColor;position:relative}.xl\:range:disabled{cursor:not-allowed;opacity:.3}}@layer daisyui.l1.l2{.xl\:range-primary{color:var(--color-primary);--range-thumb:var(--color-primary-content)}.xl\:range-secondary{color:var(--color-secondary);--range-thumb:var(--color-secondary-content)}.xl\:range-accent{color:var(--color-accent);--range-thumb:var(--color-accent-content)}.xl\:range-neutral{color:var(--color-neutral);--range-thumb:var(--color-neutral-content)}.xl\:range-success{color:var(--color-success);--range-thumb:var(--color-success-content)}.xl\:range-warning{color:var(--color-warning);--range-thumb:var(--color-warning-content)}.xl\:range-info{color:var(--color-info);--range-thumb:var(--color-info-content)}.xl\:range-error{color:var(--color-error);--range-thumb:var(--color-error-content)}.xl\:range-xs{--range-thumb-size:calc(var(--size-selector,.25rem) * 4)}.xl\:range-sm{--range-thumb-size:calc(var(--size-selector,.25rem) * 5)}.xl\:range-md{--range-thumb-size:calc(var(--size-selector,.25rem) * 6)}.xl\:range-lg{--range-thumb-size:calc(var(--size-selector,.25rem) * 7)}.xl\:range-xl{--range-thumb-size:calc(var(--size-selector,.25rem) * 8)}.xl\:range-vertical{writing-mode:vertical-lr;width:var(--range-thumb-size);--range-fill-x:0;--range-fill-y:calc(100cqh + var(--range-thumb-size) / 2);--range-fill-spread:calc(100cqh * var(--range-fill));direction:rtl;height:clamp(3rem,20rem,100%)}.xl\:range-vertical::-webkit-slider-runnable-track{height:100%;width:calc(var(--range-thumb-size) * .5)}.xl\:range-vertical::-webkit-slider-thumb{transform:translate(-50%)}.xl\:range-vertical::-moz-range-track{height:100%;width:calc(var(--range-thumb-size) * .5)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:range{-webkit-appearance:none;appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:color-mix(in oklab, currentColor 10%, #0000);--range-fill-x:calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2));--range-fill-y:0;--range-fill-spread:calc(100cqw * var(--range-fill));cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden;container-type:inline-size}[dir=rtl] .range{--range-dir:-1}.\32 xl\:range:focus{outline:none}.\32 xl\:range:focus-visible{outline-offset:2px;outline:2px solid}.\32 xl\:range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.\32 xl\:range::-webkit-slider-runnable-track{border:1px solid}}.\32 xl\:range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;-webkit-appearance:none;appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);position:relative;inset-block-start:50%;transform:translateY(-50%)}.\32 xl\:range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.\32 xl\:range::-moz-range-track{border:1px solid}}.\32 xl\:range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, var(--range-fill-x) var(--range-fill-y) 0 var(--range-fill-spread);background-color:currentColor;position:relative}.\32 xl\:range:disabled{cursor:not-allowed;opacity:.3}}@layer daisyui.l1.l2{.\32 xl\:range-primary{color:var(--color-primary);--range-thumb:var(--color-primary-content)}.\32 xl\:range-secondary{color:var(--color-secondary);--range-thumb:var(--color-secondary-content)}.\32 xl\:range-accent{color:var(--color-accent);--range-thumb:var(--color-accent-content)}.\32 xl\:range-neutral{color:var(--color-neutral);--range-thumb:var(--color-neutral-content)}.\32 xl\:range-success{color:var(--color-success);--range-thumb:var(--color-success-content)}.\32 xl\:range-warning{color:var(--color-warning);--range-thumb:var(--color-warning-content)}.\32 xl\:range-info{color:var(--color-info);--range-thumb:var(--color-info-content)}.\32 xl\:range-error{color:var(--color-error);--range-thumb:var(--color-error-content)}.\32 xl\:range-xs{--range-thumb-size:calc(var(--size-selector,.25rem) * 4)}.\32 xl\:range-sm{--range-thumb-size:calc(var(--size-selector,.25rem) * 5)}.\32 xl\:range-md{--range-thumb-size:calc(var(--size-selector,.25rem) * 6)}.\32 xl\:range-lg{--range-thumb-size:calc(var(--size-selector,.25rem) * 7)}.\32 xl\:range-xl{--range-thumb-size:calc(var(--size-selector,.25rem) * 8)}.\32 xl\:range-vertical{writing-mode:vertical-lr;width:var(--range-thumb-size);--range-fill-x:0;--range-fill-y:calc(100cqh + var(--range-thumb-size) / 2);--range-fill-spread:calc(100cqh * var(--range-fill));direction:rtl;height:clamp(3rem,20rem,100%)}.\32 xl\:range-vertical::-webkit-slider-runnable-track{height:100%;width:calc(var(--range-thumb-size) * .5)}.\32 xl\:range-vertical::-webkit-slider-thumb{transform:translate(-50%)}.\32 xl\:range-vertical::-moz-range-track{height:100%;width:calc(var(--range-thumb-size) * .5)}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.sm\:hero>*{grid-row-start:1;grid-column-start:1}.sm\:hero-overlay{background-color:color-mix(in oklab, var(--color-neutral) 50%, transparent);grid-row-start:1;grid-column-start:1;width:100%;height:100%}.sm\:hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.md\:hero>*{grid-row-start:1;grid-column-start:1}.md\:hero-overlay{background-color:color-mix(in oklab, var(--color-neutral) 50%, transparent);grid-row-start:1;grid-column-start:1;width:100%;height:100%}.md\:hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.lg\:hero>*{grid-row-start:1;grid-column-start:1}.lg\:hero-overlay{background-color:color-mix(in oklab, var(--color-neutral) 50%, transparent);grid-row-start:1;grid-column-start:1;width:100%;height:100%}.lg\:hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.xl\:hero>*{grid-row-start:1;grid-column-start:1}.xl\:hero-overlay{background-color:color-mix(in oklab, var(--color-neutral) 50%, transparent);grid-row-start:1;grid-column-start:1;width:100%;height:100%}.xl\:hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:hero{background-position:50%;background-size:cover;place-items:center;width:100%;display:grid}.\32 xl\:hero>*{grid-row-start:1;grid-column-start:1}.\32 xl\:hero-overlay{background-color:color-mix(in oklab, var(--color-neutral) 50%, transparent);grid-row-start:1;grid-column-start:1;width:100%;height:100%}.\32 xl\:hero-content{isolation:isolate;justify-content:center;align-items:center;gap:1rem;max-width:80rem;padding:1rem;display:flex}}}:is(.input[type=url],.input[type=tel],.input[type=email],.input[type=number]):dir(rtl){border-start-start-radius:var(--join-se,var(--radius-field));border-start-end-radius:var(--join-ss,var(--radius-field));border-end-end-radius:var(--join-es,var(--radius-field));border-end-start-radius:var(--join-ee,var(--radius-field))}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:input{appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;--size:calc(var(--size-field,.25rem) * var(--in-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);cursor:text;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}.sm\:input input{appearance:none;background-color:#0000;border:none;width:100%;height:100%;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&::-webkit-calendar-picker-indicator{inset-inline-end:-.15em}}.sm\:input::-webkit-inner-spin-button{margin-inline-end:-10px}.sm\:input::-webkit-calendar-picker-indicator{inset-inline-end:.75em}input.input,.sm\:input input{text-align:start;display:inline-flex;position:relative;&[type=url],&[type=tel],&[type=email],&[type=number]{direction:ltr}&::-webkit-datetime-edit,&::-webkit-date-and-time-value{min-height:100%;text-align:inherit;align-items:center;display:grid}&::-webkit-inner-spin-button{margin-block:calc(.25rem * var(--spin-my,-3))}&::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute}&::-webkit-color-swatch-wrapper{padding-block:.25rem}}.sm\:input:focus,.sm\:input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.sm\:input:focus,.sm\:input:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.sm\:input:has(>input[disabled]),.sm\:input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(input),& :is(input){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.sm\:input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.sm\:input-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.sm\:input-ghost:focus,.sm\:input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.input-neutral,.sm\:input-neutral:focus,.sm\:input-neutral:focus-within{--input-color:var(--color-neutral)}.input-primary,.sm\:input-primary:focus,.sm\:input-primary:focus-within{--input-color:var(--color-primary)}.input-secondary,.sm\:input-secondary:focus,.sm\:input-secondary:focus-within{--input-color:var(--color-secondary)}.input-accent,.sm\:input-accent:focus,.sm\:input-accent:focus-within{--input-color:var(--color-accent)}.input-info,.sm\:input-info:focus,.sm\:input-info:focus-within{--input-color:var(--color-info)}.input-success,.sm\:input-success:focus,.sm\:input-success:focus-within{--input-color:var(--color-success)}.input-warning,.sm\:input-warning:focus,.sm\:input-warning:focus-within{--input-color:var(--color-warning)}.input-error,.sm\:input-error:focus,.sm\:input-error:focus-within{--input-color:var(--color-error)}.sm\:input-xs{--in-size-mul:6;--font-size-min:.6875rem;--spin-my:-1}.sm\:floating-label:has(.input-xs){--top-mul:3;--font-size:.6875rem}.sm\:input-sm{--in-size-mul:8;--font-size-min:.75rem;--spin-my:-2}.sm\:floating-label:has(.input-sm){--top-mul:4;--font-size:.75rem}.sm\:input-md{--in-size-mul:10;--font-size-min:.875rem;--spin-my:-3}.sm\:floating-label:has(.input-md){--top-mul:5;--font-size:.875rem}.sm\:input-lg{--in-size-mul:12;--font-size-min:1.125rem;--spin-my:-3}.sm\:floating-label:has(.input-lg){--top-mul:6;--font-size:1.125rem}.sm\:input-xl{--in-size-mul:14;--font-size-min:1.375rem;--spin-my:-4}.sm\:floating-label:has(.input-xl){--top-mul:7;--font-size:1.375rem}}:is(.input[type=url],.input[type=tel],.input[type=email],.input[type=number]):dir(rtl){border-start-start-radius:var(--join-se,var(--radius-field));border-start-end-radius:var(--join-ss,var(--radius-field));border-end-end-radius:var(--join-es,var(--radius-field));border-end-start-radius:var(--join-ee,var(--radius-field))}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:input{appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;--size:calc(var(--size-field,.25rem) * var(--in-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);cursor:text;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}.md\:input input{appearance:none;background-color:#0000;border:none;width:100%;height:100%;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&::-webkit-calendar-picker-indicator{inset-inline-end:-.15em}}.md\:input::-webkit-inner-spin-button{margin-inline-end:-10px}.md\:input::-webkit-calendar-picker-indicator{inset-inline-end:.75em}input.input,.md\:input input{text-align:start;display:inline-flex;position:relative;&[type=url],&[type=tel],&[type=email],&[type=number]{direction:ltr}&::-webkit-datetime-edit,&::-webkit-date-and-time-value{min-height:100%;text-align:inherit;align-items:center;display:grid}&::-webkit-inner-spin-button{margin-block:calc(.25rem * var(--spin-my,-3))}&::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute}&::-webkit-color-swatch-wrapper{padding-block:.25rem}}.md\:input:focus,.md\:input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.md\:input:focus,.md\:input:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.md\:input:has(>input[disabled]),.md\:input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(input),& :is(input){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.md\:input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.md\:input-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.md\:input-ghost:focus,.md\:input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.input-neutral,.md\:input-neutral:focus,.md\:input-neutral:focus-within{--input-color:var(--color-neutral)}.input-primary,.md\:input-primary:focus,.md\:input-primary:focus-within{--input-color:var(--color-primary)}.input-secondary,.md\:input-secondary:focus,.md\:input-secondary:focus-within{--input-color:var(--color-secondary)}.input-accent,.md\:input-accent:focus,.md\:input-accent:focus-within{--input-color:var(--color-accent)}.input-info,.md\:input-info:focus,.md\:input-info:focus-within{--input-color:var(--color-info)}.input-success,.md\:input-success:focus,.md\:input-success:focus-within{--input-color:var(--color-success)}.input-warning,.md\:input-warning:focus,.md\:input-warning:focus-within{--input-color:var(--color-warning)}.input-error,.md\:input-error:focus,.md\:input-error:focus-within{--input-color:var(--color-error)}.md\:input-xs{--in-size-mul:6;--font-size-min:.6875rem;--spin-my:-1}.md\:floating-label:has(.input-xs){--top-mul:3;--font-size:.6875rem}.md\:input-sm{--in-size-mul:8;--font-size-min:.75rem;--spin-my:-2}.md\:floating-label:has(.input-sm){--top-mul:4;--font-size:.75rem}.md\:input-md{--in-size-mul:10;--font-size-min:.875rem;--spin-my:-3}.md\:floating-label:has(.input-md){--top-mul:5;--font-size:.875rem}.md\:input-lg{--in-size-mul:12;--font-size-min:1.125rem;--spin-my:-3}.md\:floating-label:has(.input-lg){--top-mul:6;--font-size:1.125rem}.md\:input-xl{--in-size-mul:14;--font-size-min:1.375rem;--spin-my:-4}.md\:floating-label:has(.input-xl){--top-mul:7;--font-size:1.375rem}}:is(.input[type=url],.input[type=tel],.input[type=email],.input[type=number]):dir(rtl){border-start-start-radius:var(--join-se,var(--radius-field));border-start-end-radius:var(--join-ss,var(--radius-field));border-end-end-radius:var(--join-es,var(--radius-field));border-end-start-radius:var(--join-ee,var(--radius-field))}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:input{appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;--size:calc(var(--size-field,.25rem) * var(--in-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);cursor:text;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}.lg\:input input{appearance:none;background-color:#0000;border:none;width:100%;height:100%;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&::-webkit-calendar-picker-indicator{inset-inline-end:-.15em}}.lg\:input::-webkit-inner-spin-button{margin-inline-end:-10px}.lg\:input::-webkit-calendar-picker-indicator{inset-inline-end:.75em}input.input,.lg\:input input{text-align:start;display:inline-flex;position:relative;&[type=url],&[type=tel],&[type=email],&[type=number]{direction:ltr}&::-webkit-datetime-edit,&::-webkit-date-and-time-value{min-height:100%;text-align:inherit;align-items:center;display:grid}&::-webkit-inner-spin-button{margin-block:calc(.25rem * var(--spin-my,-3))}&::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute}&::-webkit-color-swatch-wrapper{padding-block:.25rem}}.lg\:input:focus,.lg\:input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.lg\:input:focus,.lg\:input:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.lg\:input:has(>input[disabled]),.lg\:input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(input),& :is(input){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.lg\:input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.lg\:input-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.lg\:input-ghost:focus,.lg\:input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.input-neutral,.lg\:input-neutral:focus,.lg\:input-neutral:focus-within{--input-color:var(--color-neutral)}.input-primary,.lg\:input-primary:focus,.lg\:input-primary:focus-within{--input-color:var(--color-primary)}.input-secondary,.lg\:input-secondary:focus,.lg\:input-secondary:focus-within{--input-color:var(--color-secondary)}.input-accent,.lg\:input-accent:focus,.lg\:input-accent:focus-within{--input-color:var(--color-accent)}.input-info,.lg\:input-info:focus,.lg\:input-info:focus-within{--input-color:var(--color-info)}.input-success,.lg\:input-success:focus,.lg\:input-success:focus-within{--input-color:var(--color-success)}.input-warning,.lg\:input-warning:focus,.lg\:input-warning:focus-within{--input-color:var(--color-warning)}.input-error,.lg\:input-error:focus,.lg\:input-error:focus-within{--input-color:var(--color-error)}.lg\:input-xs{--in-size-mul:6;--font-size-min:.6875rem;--spin-my:-1}.lg\:floating-label:has(.input-xs){--top-mul:3;--font-size:.6875rem}.lg\:input-sm{--in-size-mul:8;--font-size-min:.75rem;--spin-my:-2}.lg\:floating-label:has(.input-sm){--top-mul:4;--font-size:.75rem}.lg\:input-md{--in-size-mul:10;--font-size-min:.875rem;--spin-my:-3}.lg\:floating-label:has(.input-md){--top-mul:5;--font-size:.875rem}.lg\:input-lg{--in-size-mul:12;--font-size-min:1.125rem;--spin-my:-3}.lg\:floating-label:has(.input-lg){--top-mul:6;--font-size:1.125rem}.lg\:input-xl{--in-size-mul:14;--font-size-min:1.375rem;--spin-my:-4}.lg\:floating-label:has(.input-xl){--top-mul:7;--font-size:1.375rem}}:is(.input[type=url],.input[type=tel],.input[type=email],.input[type=number]):dir(rtl){border-start-start-radius:var(--join-se,var(--radius-field));border-start-end-radius:var(--join-ss,var(--radius-field));border-end-end-radius:var(--join-es,var(--radius-field));border-end-start-radius:var(--join-ee,var(--radius-field))}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:input{appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;--size:calc(var(--size-field,.25rem) * var(--in-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);cursor:text;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}.xl\:input input{appearance:none;background-color:#0000;border:none;width:100%;height:100%;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&::-webkit-calendar-picker-indicator{inset-inline-end:-.15em}}.xl\:input::-webkit-inner-spin-button{margin-inline-end:-10px}.xl\:input::-webkit-calendar-picker-indicator{inset-inline-end:.75em}input.input,.xl\:input input{text-align:start;display:inline-flex;position:relative;&[type=url],&[type=tel],&[type=email],&[type=number]{direction:ltr}&::-webkit-datetime-edit,&::-webkit-date-and-time-value{min-height:100%;text-align:inherit;align-items:center;display:grid}&::-webkit-inner-spin-button{margin-block:calc(.25rem * var(--spin-my,-3))}&::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute}&::-webkit-color-swatch-wrapper{padding-block:.25rem}}.xl\:input:focus,.xl\:input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.xl\:input:focus,.xl\:input:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.xl\:input:has(>input[disabled]),.xl\:input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(input),& :is(input){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.xl\:input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.xl\:input-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.xl\:input-ghost:focus,.xl\:input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.input-neutral,.xl\:input-neutral:focus,.xl\:input-neutral:focus-within{--input-color:var(--color-neutral)}.input-primary,.xl\:input-primary:focus,.xl\:input-primary:focus-within{--input-color:var(--color-primary)}.input-secondary,.xl\:input-secondary:focus,.xl\:input-secondary:focus-within{--input-color:var(--color-secondary)}.input-accent,.xl\:input-accent:focus,.xl\:input-accent:focus-within{--input-color:var(--color-accent)}.input-info,.xl\:input-info:focus,.xl\:input-info:focus-within{--input-color:var(--color-info)}.input-success,.xl\:input-success:focus,.xl\:input-success:focus-within{--input-color:var(--color-success)}.input-warning,.xl\:input-warning:focus,.xl\:input-warning:focus-within{--input-color:var(--color-warning)}.input-error,.xl\:input-error:focus,.xl\:input-error:focus-within{--input-color:var(--color-error)}.xl\:input-xs{--in-size-mul:6;--font-size-min:.6875rem;--spin-my:-1}.xl\:floating-label:has(.input-xs){--top-mul:3;--font-size:.6875rem}.xl\:input-sm{--in-size-mul:8;--font-size-min:.75rem;--spin-my:-2}.xl\:floating-label:has(.input-sm){--top-mul:4;--font-size:.75rem}.xl\:input-md{--in-size-mul:10;--font-size-min:.875rem;--spin-my:-3}.xl\:floating-label:has(.input-md){--top-mul:5;--font-size:.875rem}.xl\:input-lg{--in-size-mul:12;--font-size-min:1.125rem;--spin-my:-3}.xl\:floating-label:has(.input-lg){--top-mul:6;--font-size:1.125rem}.xl\:input-xl{--in-size-mul:14;--font-size-min:1.375rem;--spin-my:-4}.xl\:floating-label:has(.input-xl){--top-mul:7;--font-size:1.375rem}}:is(.input[type=url],.input[type=tel],.input[type=email],.input[type=number]):dir(rtl){border-start-start-radius:var(--join-se,var(--radius-field));border-start-end-radius:var(--join-ss,var(--radius-field));border-end-end-radius:var(--join-es,var(--radius-field));border-end-start-radius:var(--join-ee,var(--radius-field))}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:input{appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;--size:calc(var(--size-field,.25rem) * var(--in-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);cursor:text;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}.\32 xl\:input input{appearance:none;background-color:#0000;border:none;width:100%;height:100%;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&::-webkit-calendar-picker-indicator{inset-inline-end:-.15em}}.\32 xl\:input::-webkit-inner-spin-button{margin-inline-end:-10px}.\32 xl\:input::-webkit-calendar-picker-indicator{inset-inline-end:.75em}input.input,.\32 xl\:input input{text-align:start;display:inline-flex;position:relative;&[type=url],&[type=tel],&[type=email],&[type=number]{direction:ltr}&::-webkit-datetime-edit,&::-webkit-date-and-time-value{min-height:100%;text-align:inherit;align-items:center;display:grid}&::-webkit-inner-spin-button{margin-block:calc(.25rem * var(--spin-my,-3))}&::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute}&::-webkit-color-swatch-wrapper{padding-block:.25rem}}.\32 xl\:input:focus,.\32 xl\:input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.\32 xl\:input:focus,.\32 xl\:input:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.\32 xl\:input:has(>input[disabled]),.\32 xl\:input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(input),& :is(input){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.\32 xl\:input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.\32 xl\:input-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.\32 xl\:input-ghost:focus,.\32 xl\:input-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.input-neutral,.\32 xl\:input-neutral:focus,.\32 xl\:input-neutral:focus-within{--input-color:var(--color-neutral)}.input-primary,.\32 xl\:input-primary:focus,.\32 xl\:input-primary:focus-within{--input-color:var(--color-primary)}.input-secondary,.\32 xl\:input-secondary:focus,.\32 xl\:input-secondary:focus-within{--input-color:var(--color-secondary)}.input-accent,.\32 xl\:input-accent:focus,.\32 xl\:input-accent:focus-within{--input-color:var(--color-accent)}.input-info,.\32 xl\:input-info:focus,.\32 xl\:input-info:focus-within{--input-color:var(--color-info)}.input-success,.\32 xl\:input-success:focus,.\32 xl\:input-success:focus-within{--input-color:var(--color-success)}.input-warning,.\32 xl\:input-warning:focus,.\32 xl\:input-warning:focus-within{--input-color:var(--color-warning)}.input-error,.\32 xl\:input-error:focus,.\32 xl\:input-error:focus-within{--input-color:var(--color-error)}.\32 xl\:input-xs{--in-size-mul:6;--font-size-min:.6875rem;--spin-my:-1}.\32 xl\:floating-label:has(.input-xs){--top-mul:3;--font-size:.6875rem}.\32 xl\:input-sm{--in-size-mul:8;--font-size-min:.75rem;--spin-my:-2}.\32 xl\:floating-label:has(.input-sm){--top-mul:4;--font-size:.75rem}.\32 xl\:input-md{--in-size-mul:10;--font-size-min:.875rem;--spin-my:-3}.\32 xl\:floating-label:has(.input-md){--top-mul:5;--font-size:.875rem}.\32 xl\:input-lg{--in-size-mul:12;--font-size-min:1.125rem;--spin-my:-3}.\32 xl\:floating-label:has(.input-lg){--top-mul:6;--font-size:1.125rem}.\32 xl\:input-xl{--in-size-mul:14;--font-size-min:1.375rem;--spin-my:-4}.\32 xl\:floating-label:has(.input-xl){--top-mul:7;--font-size:1.375rem}}:is(.input[type=url],.input[type=tel],.input[type=email],.input[type=number]):dir(rtl){border-start-start-radius:var(--join-se,var(--radius-field));border-start-end-radius:var(--join-ss,var(--radius-field));border-end-end-radius:var(--join-es,var(--radius-field));border-end-start-radius:var(--join-ee,var(--radius-field))}}.collapse:not(td,tr,colgroup){visibility:revert-layer;@layer daisyui.l1.l2.l3{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;@media (prefers-reduced-motion:no-preference){transition:grid-template-rows .2s}&>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;grid-row-start:1;grid-column-start:1}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),&:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;@supports not (content-visibility:visible){visibility:visible}}&:focus-visible,&:has(>input:is([type=checkbox],[type=radio]):focus-visible),&:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}&:not(.collapse-close){&>input[type=checkbox],&>input[type=radio]:not(:checked),&>.collapse-title{cursor:pointer}}&[tabindex]:focus:not(.collapse-close,.collapse[open]),&[tabindex]:focus-within:not(.collapse-close,.collapse[open]){&>.collapse-title{cursor:unset}}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}&>input:is([type=checkbox],[type=radio]){z-index:1;padding:1rem;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out}&:is(details){width:100%;&::details-content{--overflow-delay:0s;height:0;overflow:clip}&:where([open])::details-content{overflow:revert-layer;height:auto}@media (prefers-reduced-motion:no-preference){&::details-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords}&:where([open])::details-content{--overflow-delay:.2s}}&>summary{outline:none;display:block;position:relative;&::-webkit-details-marker{display:none}}}}@layer daisyui.l1.l2{&[open]{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&.collapse-open{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&[tabindex].collapse-arrow:focus:not(.collapse-close),&.collapse-arrow[tabindex]:focus-within:not(.collapse-close){&>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&.collapse-arrow:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&[open]{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&.collapse-open{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&[tabindex].collapse-plus:focus:not(.collapse-close){&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}&.collapse-plus:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}}@media (width>=640px){.sm\:collapse:not(td,tr,colgroup){visibility:revert-layer;@layer daisyui.l1.l2.l3{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;@media (prefers-reduced-motion:no-preference){transition:grid-template-rows .2s}&>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;grid-row-start:1;grid-column-start:1}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),&:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;@supports not (content-visibility:visible){visibility:visible}}&:focus-visible,&:has(>input:is([type=checkbox],[type=radio]):focus-visible),&:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}&:not(.collapse-close){&>input[type=checkbox],&>input[type=radio]:not(:checked),&>.collapse-title{cursor:pointer}}&[tabindex]:focus:not(.collapse-close,.collapse[open]),&[tabindex]:focus-within:not(.collapse-close,.collapse[open]){&>.collapse-title{cursor:unset}}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}&>input:is([type=checkbox],[type=radio]){z-index:1;padding:1rem;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out}&:is(details){width:100%;&::details-content{--overflow-delay:0s;height:0;overflow:clip}&:where([open])::details-content{overflow:revert-layer;height:auto}@media (prefers-reduced-motion:no-preference){&::details-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords}&:where([open])::details-content{--overflow-delay:.2s}}&>summary{outline:none;display:block;position:relative;&::-webkit-details-marker{display:none}}}}@layer daisyui.l1.l2{&[open]{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&.collapse-open{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&[tabindex].collapse-arrow:focus:not(.collapse-close),&.collapse-arrow[tabindex]:focus-within:not(.collapse-close){&>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&.collapse-arrow:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&[open]{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&.collapse-open{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&[tabindex].collapse-plus:focus:not(.collapse-close){&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}&.collapse-plus:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}}@layer daisyui.l1.l2.l3{.sm\:collapse-content{--overflow-delay:0s;content-visibility:hidden;min-height:0;cursor:unset;grid-row-start:2;grid-column-start:1;padding-left:1rem;padding-right:1rem;overflow:clip}@supports not (content-visibility:hidden){.sm\:collapse-content{visibility:hidden}}@media (prefers-reduced-motion:no-preference){.sm\:collapse-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out}}details>.collapse-content{content-visibility:visible}.sm\:collapse-title{padding:1rem;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out;position:relative}}@layer daisyui.l1.l2{.sm\:collapse-arrow>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;transform:translateY(-100%)rotate(45deg);@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:50%;content:"";transform-origin:75% 75%;pointer-events:none;inset-inline-end:1.4rem;box-shadow:2px 2px}.sm\:collapse-plus>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:.9rem;--tw-content:"+";content:var(--tw-content);pointer-events:none;inset-inline-end:1.4rem}.sm\:collapse-open{grid-template-rows:max-content 1fr}.sm\:collapse-open>.collapse-content{--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;padding-bottom:1rem;@supports not (content-visibility:visible){visibility:visible}}}}@media (width>=768px){.md\:collapse:not(td,tr,colgroup){visibility:revert-layer;@layer daisyui.l1.l2.l3{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;@media (prefers-reduced-motion:no-preference){transition:grid-template-rows .2s}&>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;grid-row-start:1;grid-column-start:1}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),&:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;@supports not (content-visibility:visible){visibility:visible}}&:focus-visible,&:has(>input:is([type=checkbox],[type=radio]):focus-visible),&:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}&:not(.collapse-close){&>input[type=checkbox],&>input[type=radio]:not(:checked),&>.collapse-title{cursor:pointer}}&[tabindex]:focus:not(.collapse-close,.collapse[open]),&[tabindex]:focus-within:not(.collapse-close,.collapse[open]){&>.collapse-title{cursor:unset}}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}&>input:is([type=checkbox],[type=radio]){z-index:1;padding:1rem;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out}&:is(details){width:100%;&::details-content{--overflow-delay:0s;height:0;overflow:clip}&:where([open])::details-content{overflow:revert-layer;height:auto}@media (prefers-reduced-motion:no-preference){&::details-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords}&:where([open])::details-content{--overflow-delay:.2s}}&>summary{outline:none;display:block;position:relative;&::-webkit-details-marker{display:none}}}}@layer daisyui.l1.l2{&[open]{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&.collapse-open{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&[tabindex].collapse-arrow:focus:not(.collapse-close),&.collapse-arrow[tabindex]:focus-within:not(.collapse-close){&>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&.collapse-arrow:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&[open]{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&.collapse-open{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&[tabindex].collapse-plus:focus:not(.collapse-close){&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}&.collapse-plus:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}}@layer daisyui.l1.l2.l3{.md\:collapse-content{--overflow-delay:0s;content-visibility:hidden;min-height:0;cursor:unset;grid-row-start:2;grid-column-start:1;padding-left:1rem;padding-right:1rem;overflow:clip}@supports not (content-visibility:hidden){.md\:collapse-content{visibility:hidden}}@media (prefers-reduced-motion:no-preference){.md\:collapse-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out}}details>.collapse-content{content-visibility:visible}.md\:collapse-title{padding:1rem;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out;position:relative}}@layer daisyui.l1.l2{.md\:collapse-arrow>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;transform:translateY(-100%)rotate(45deg);@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:50%;content:"";transform-origin:75% 75%;pointer-events:none;inset-inline-end:1.4rem;box-shadow:2px 2px}.md\:collapse-plus>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:.9rem;--tw-content:"+";content:var(--tw-content);pointer-events:none;inset-inline-end:1.4rem}.md\:collapse-open{grid-template-rows:max-content 1fr}.md\:collapse-open>.collapse-content{--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;padding-bottom:1rem;@supports not (content-visibility:visible){visibility:visible}}}}@media (width>=1024px){.lg\:collapse:not(td,tr,colgroup){visibility:revert-layer;@layer daisyui.l1.l2.l3{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;@media (prefers-reduced-motion:no-preference){transition:grid-template-rows .2s}&>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;grid-row-start:1;grid-column-start:1}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),&:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;@supports not (content-visibility:visible){visibility:visible}}&:focus-visible,&:has(>input:is([type=checkbox],[type=radio]):focus-visible),&:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}&:not(.collapse-close){&>input[type=checkbox],&>input[type=radio]:not(:checked),&>.collapse-title{cursor:pointer}}&[tabindex]:focus:not(.collapse-close,.collapse[open]),&[tabindex]:focus-within:not(.collapse-close,.collapse[open]){&>.collapse-title{cursor:unset}}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}&>input:is([type=checkbox],[type=radio]){z-index:1;padding:1rem;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out}&:is(details){width:100%;&::details-content{--overflow-delay:0s;height:0;overflow:clip}&:where([open])::details-content{overflow:revert-layer;height:auto}@media (prefers-reduced-motion:no-preference){&::details-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords}&:where([open])::details-content{--overflow-delay:.2s}}&>summary{outline:none;display:block;position:relative;&::-webkit-details-marker{display:none}}}}@layer daisyui.l1.l2{&[open]{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&.collapse-open{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&[tabindex].collapse-arrow:focus:not(.collapse-close),&.collapse-arrow[tabindex]:focus-within:not(.collapse-close){&>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&.collapse-arrow:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&[open]{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&.collapse-open{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&[tabindex].collapse-plus:focus:not(.collapse-close){&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}&.collapse-plus:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}}@layer daisyui.l1.l2.l3{.lg\:collapse-content{--overflow-delay:0s;content-visibility:hidden;min-height:0;cursor:unset;grid-row-start:2;grid-column-start:1;padding-left:1rem;padding-right:1rem;overflow:clip}@supports not (content-visibility:hidden){.lg\:collapse-content{visibility:hidden}}@media (prefers-reduced-motion:no-preference){.lg\:collapse-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out}}details>.collapse-content{content-visibility:visible}.lg\:collapse-title{padding:1rem;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out;position:relative}}@layer daisyui.l1.l2{.lg\:collapse-arrow>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;transform:translateY(-100%)rotate(45deg);@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:50%;content:"";transform-origin:75% 75%;pointer-events:none;inset-inline-end:1.4rem;box-shadow:2px 2px}.lg\:collapse-plus>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:.9rem;--tw-content:"+";content:var(--tw-content);pointer-events:none;inset-inline-end:1.4rem}.lg\:collapse-open{grid-template-rows:max-content 1fr}.lg\:collapse-open>.collapse-content{--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;padding-bottom:1rem;@supports not (content-visibility:visible){visibility:visible}}}}@media (width>=1280px){.xl\:collapse:not(td,tr,colgroup){visibility:revert-layer;@layer daisyui.l1.l2.l3{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;@media (prefers-reduced-motion:no-preference){transition:grid-template-rows .2s}&>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;grid-row-start:1;grid-column-start:1}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),&:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;@supports not (content-visibility:visible){visibility:visible}}&:focus-visible,&:has(>input:is([type=checkbox],[type=radio]):focus-visible),&:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}&:not(.collapse-close){&>input[type=checkbox],&>input[type=radio]:not(:checked),&>.collapse-title{cursor:pointer}}&[tabindex]:focus:not(.collapse-close,.collapse[open]),&[tabindex]:focus-within:not(.collapse-close,.collapse[open]){&>.collapse-title{cursor:unset}}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}&>input:is([type=checkbox],[type=radio]){z-index:1;padding:1rem;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out}&:is(details){width:100%;&::details-content{--overflow-delay:0s;height:0;overflow:clip}&:where([open])::details-content{overflow:revert-layer;height:auto}@media (prefers-reduced-motion:no-preference){&::details-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords}&:where([open])::details-content{--overflow-delay:.2s}}&>summary{outline:none;display:block;position:relative;&::-webkit-details-marker{display:none}}}}@layer daisyui.l1.l2{&[open]{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&.collapse-open{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&[tabindex].collapse-arrow:focus:not(.collapse-close),&.collapse-arrow[tabindex]:focus-within:not(.collapse-close){&>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&.collapse-arrow:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&[open]{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&.collapse-open{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&[tabindex].collapse-plus:focus:not(.collapse-close){&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}&.collapse-plus:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}}@layer daisyui.l1.l2.l3{.xl\:collapse-content{--overflow-delay:0s;content-visibility:hidden;min-height:0;cursor:unset;grid-row-start:2;grid-column-start:1;padding-left:1rem;padding-right:1rem;overflow:clip}@supports not (content-visibility:hidden){.xl\:collapse-content{visibility:hidden}}@media (prefers-reduced-motion:no-preference){.xl\:collapse-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out}}details>.collapse-content{content-visibility:visible}.xl\:collapse-title{padding:1rem;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out;position:relative}}@layer daisyui.l1.l2{.xl\:collapse-arrow>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;transform:translateY(-100%)rotate(45deg);@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:50%;content:"";transform-origin:75% 75%;pointer-events:none;inset-inline-end:1.4rem;box-shadow:2px 2px}.xl\:collapse-plus>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:.9rem;--tw-content:"+";content:var(--tw-content);pointer-events:none;inset-inline-end:1.4rem}.xl\:collapse-open{grid-template-rows:max-content 1fr}.xl\:collapse-open>.collapse-content{--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;padding-bottom:1rem;@supports not (content-visibility:visible){visibility:visible}}}}@media (width>=1536px){.\32 xl\:collapse:not(td,tr,colgroup){visibility:revert-layer;@layer daisyui.l1.l2.l3{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;@media (prefers-reduced-motion:no-preference){transition:grid-template-rows .2s}&>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;grid-row-start:1;grid-column-start:1}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),&:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;@supports not (content-visibility:visible){visibility:visible}}&:focus-visible,&:has(>input:is([type=checkbox],[type=radio]):focus-visible),&:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}&:not(.collapse-close){&>input[type=checkbox],&>input[type=radio]:not(:checked),&>.collapse-title{cursor:pointer}}&[tabindex]:focus:not(.collapse-close,.collapse[open]),&[tabindex]:focus-within:not(.collapse-close,.collapse[open]){&>.collapse-title{cursor:unset}}&:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),&:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}&>input:is([type=checkbox],[type=radio]){z-index:1;padding:1rem;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out}&:is(details){width:100%;&::details-content{--overflow-delay:0s;height:0;overflow:clip}&:where([open])::details-content{overflow:revert-layer;height:auto}@media (prefers-reduced-motion:no-preference){&::details-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords}&:where([open])::details-content{--overflow-delay:.2s}}&>summary{outline:none;display:block;position:relative;&::-webkit-details-marker{display:none}}}}@layer daisyui.l1.l2{&[open]{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&.collapse-open{&.collapse-arrow{&>.collapse-title:after{@media (prefers-reduced-motion:no-preference){transform:translateY(-50%)rotate(225deg)}}}}&[tabindex].collapse-arrow:focus:not(.collapse-close),&.collapse-arrow[tabindex]:focus-within:not(.collapse-close){&>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&.collapse-arrow:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}&[open]{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&.collapse-open{&.collapse-plus{&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}&[tabindex].collapse-plus:focus:not(.collapse-close){&>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}&.collapse-plus:not(.collapse-close){&>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}}}}@layer daisyui.l1.l2.l3{.\32 xl\:collapse-content{--overflow-delay:0s;content-visibility:hidden;min-height:0;cursor:unset;grid-row-start:2;grid-column-start:1;padding-left:1rem;padding-right:1rem;overflow:clip}@supports not (content-visibility:hidden){.\32 xl\:collapse-content{visibility:hidden}}@media (prefers-reduced-motion:no-preference){.\32 xl\:collapse-content{transition:overflow .2s allow-discrete var(--overflow-delay), content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out}}details>.collapse-content{content-visibility:visible}.\32 xl\:collapse-title{padding:1rem;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding-inline-end:3rem;transition:background-color .2s ease-out;position:relative}}@layer daisyui.l1.l2{.\32 xl\:collapse-arrow>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;transform:translateY(-100%)rotate(45deg);@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:50%;content:"";transform-origin:75% 75%;pointer-events:none;inset-inline-end:1.4rem;box-shadow:2px 2px}.\32 xl\:collapse-plus>.collapse-title:after{width:.5rem;height:.5rem;display:block;position:absolute;@media (prefers-reduced-motion:no-preference){transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}top:.9rem;--tw-content:"+";content:var(--tw-content);pointer-events:none;inset-inline-end:1.4rem}.\32 xl\:collapse-open{grid-template-rows:max-content 1fr}.\32 xl\:collapse-open>.collapse-content{--overflow-delay:.2s;overflow:revert-layer;content-visibility:visible;min-height:fit-content;padding-bottom:1rem;@supports not (content-visibility:visible){visibility:visible}}}}@layer daisyui.l1{.list-col-wrap{grid-row-start:2}.alert-soft{color:var(--alert-color,var(--color-base-content));background:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 8%, var(--color-base-100));--alert-border-color:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 10%, var(--color-base-100));box-shadow:none;background-image:none}.alert-outline{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none}.alert-dash{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none;border-style:dashed}.megamenu:popover-open{opacity:1;background-color:var(--color-base-100);width:100%;margin-top:var(--mm-mt,4rem);border-radius:0;flex-direction:column;align-items:flex-start;padding-top:1rem;position:fixed;overflow-y:scroll;& [popovertarget],&>[popover]:not(:first-of-type){display:var(--mm-display,none)}&::backdrop{background-color:var(--mm-backdrop,oklch(0% 0 0/.4))}position-area:block-end;top:0;max-block-size:80svh;transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;inset-inline-start:0;translate:0;scale:1;@starting-style{opacity:0}& [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}& [popover]{opacity:1;background-color:#0000;border:none;max-height:none;display:block;position:relative;overflow:visible}}.filter:not(:has(:checked:not(.filter-reset))) :is(.filter-reset,[type=reset]):not(:focus-visible){visibility:hidden}.filter:not(:has(:checked:not(.filter-reset))) :is(.filter-reset,[type=reset]):not(:focus-visible),.filter:not(:has(:focus-visible)):has(:checked:not(.filter-reset,[type=checkbox])) :is(input,button):not(:checked,.filter-reset,[type=reset]){opacity:0;border-width:0;width:0;margin-inline:0;padding-inline:0;scale:0}.swap-active .swap-off{opacity:0}.swap-active .swap-on{opacity:1}.swap-active.swap-rotate .swap-on{rotate:0deg}.swap-active.swap-rotate .swap-off{rotate:-45deg}.swap-active.swap-flip .swap-on{transform:rotateY(0)}.swap-active.swap-flip .swap-off{backface-visibility:hidden;opacity:1;transform:rotateY(-180deg)}.swap-rotate .swap-on,.swap-rotate input:indeterminate~.swap-on{rotate:45deg}.swap-rotate input:is(:checked,:indeterminate)~.swap-on,.swap-rotate.swap-active .swap-on{rotate:0deg}.swap-rotate input:is(:checked,:indeterminate)~.swap-off,.swap-rotate.swap-active .swap-off{rotate:-45deg}.swap-flip{transform-style:preserve-3d;perspective:20rem}.swap-flip .swap-on,.swap-flip .swap-indeterminate,.swap-flip input:indeterminate~.swap-on{backface-visibility:hidden;transform:rotateY(180deg)}.swap-flip input:is(:checked,:indeterminate)~.swap-on,.swap-flip.swap-active .swap-on{transform:rotateY(0)}.swap-flip input:is(:checked,:indeterminate)~.swap-off,.swap-flip.swap-active .swap-off{backface-visibility:hidden;opacity:1;transform:rotateY(-180deg)}@media (hover:hover){.btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);color:var(--btn-fg);--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.btn:active:not(.btn-active,[aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);translate:0 .5px}.btn:where(:checked:not(.filter [type=radio].btn),:not([type=radio],[type=checkbox]):focus-visible){--btn-bg:var(--btn-color,var(--color-base-200));color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);isolation:isolate}.btn:focus-visible,.btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}.drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:0%;[dir=rtl] &{translate:0%}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:list{flex-direction:column;font-size:.875rem;display:flex}.sm\:list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent);position:absolute;bottom:0}}@layer daisyui.l1.l2{.sm\:list .list-row:has(>.list-col-grow:first-child){--list-grid-cols:1fr}.sm\:list .list-row:has(>.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.sm\:list .list-row:has(>.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.sm\:list .list-row:has(>.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.sm\:list .list-row:has(>.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.sm\:list .list-row:has(>.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.sm\:list .list-row>*{grid-row-start:1}}@layer daisyui.l1{.sm\:list-col-wrap{grid-row-start:2}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:list{flex-direction:column;font-size:.875rem;display:flex}.md\:list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent);position:absolute;bottom:0}}@layer daisyui.l1.l2{.md\:list .list-row:has(>.list-col-grow:first-child){--list-grid-cols:1fr}.md\:list .list-row:has(>.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.md\:list .list-row:has(>.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.md\:list .list-row:has(>.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.md\:list .list-row:has(>.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.md\:list .list-row:has(>.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.md\:list .list-row>*{grid-row-start:1}}@layer daisyui.l1{.md\:list-col-wrap{grid-row-start:2}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:list{flex-direction:column;font-size:.875rem;display:flex}.lg\:list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent);position:absolute;bottom:0}}@layer daisyui.l1.l2{.lg\:list .list-row:has(>.list-col-grow:first-child){--list-grid-cols:1fr}.lg\:list .list-row:has(>.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.lg\:list .list-row:has(>.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.lg\:list .list-row:has(>.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.lg\:list .list-row:has(>.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.lg\:list .list-row:has(>.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.lg\:list .list-row>*{grid-row-start:1}}@layer daisyui.l1{.lg\:list-col-wrap{grid-row-start:2}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:list{flex-direction:column;font-size:.875rem;display:flex}.xl\:list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent);position:absolute;bottom:0}}@layer daisyui.l1.l2{.xl\:list .list-row:has(>.list-col-grow:first-child){--list-grid-cols:1fr}.xl\:list .list-row:has(>.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.xl\:list .list-row:has(>.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.xl\:list .list-row:has(>.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.xl\:list .list-row:has(>.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.xl\:list .list-row:has(>.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.xl\:list .list-row>*{grid-row-start:1}}@layer daisyui.l1{.xl\:list-col-wrap{grid-row-start:2}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:list{flex-direction:column;font-size:.875rem;display:flex}.\32 xl\:list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent);position:absolute;bottom:0}}@layer daisyui.l1.l2{.\32 xl\:list .list-row:has(>.list-col-grow:first-child){--list-grid-cols:1fr}.\32 xl\:list .list-row:has(>.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.\32 xl\:list .list-row:has(>.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.\32 xl\:list .list-row:has(>.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.\32 xl\:list .list-row:has(>.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.\32 xl\:list .list-row:has(>.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.\32 xl\:list .list-row>*{grid-row-start:1}}@layer daisyui.l1{.\32 xl\:list-col-wrap{grid-row-start:2}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:fab{pointer-events:none;inset-inline-end:1rem;z-index:999;white-space:nowrap;-webkit-user-select:none;user-select:none;flex-direction:column-reverse;align-items:flex-end;gap:.5rem;font-size:.875rem;line-height:1.25rem;display:flex;position:fixed;bottom:1rem}.sm\:fab>*{pointer-events:auto;align-items:center;gap:.5rem;display:flex;&:hover,&:has(:focus-visible){z-index:1}}.sm\:fab>[tabindex]:first-child{transition-property:opacity,visibility,rotate;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:grid;position:relative}.sm\:fab .fab-close,.sm\:fab .fab-main-action{inset-inline-end:0;position:absolute;bottom:0}:is(.fab:focus-within:has(.fab-close),.fab:focus-within:has(.fab-main-action))>[tabindex]{opacity:0;rotate:90deg}.sm\:fab>:nth-child(n+2){visibility:hidden;--tw-scale-x:80%;--tw-scale-y:80%;--tw-scale-z:80%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:0;transition-property:opacity,scale,visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);&.fab-main-action,&.fab-close{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.sm\:fab>:nth-child(3){transition-delay:30ms}.sm\:fab>:nth-child(4){transition-delay:60ms}.sm\:fab>:nth-child(5){transition-delay:90ms}.sm\:fab>:nth-child(6){transition-delay:.12s}.sm\:fab:focus-within>[tabindex]:first-child{pointer-events:none}.sm\:fab:focus-within>:nth-child(n+2){visibility:visible;--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}.sm\:fab-flower{--position:0rem;display:grid}.sm\:fab-flower>:first-child,.sm\:fab-flower>.fab-main-action,.sm\:fab-flower>.fab-close{--position:0rem}.sm\:fab-flower>*{--degree:180deg;--flip-degree:calc(180deg - var(--degree));transform:translateX(calc(cos(var(--degree)) * var(--position))) translateY(calc(sin(var(--degree)) * -1 * var(--position)));grid-area:1/1;[dir=rtl] &{transform:translateX(calc(cos(var(--flip-degree)) * var(--position))) translateY(calc(sin(var(--flip-degree)) * -1 * var(--position)))}}.sm\:fab-flower>:nth-child(n+7){display:none}.sm\:fab-flower:has(>:nth-child(3)){--position:140%;&>:nth-child(3){--degree:135deg}}.sm\:fab-flower:has(>:nth-child(4)){--position:140%;&>:nth-child(3){--degree:165deg}&>:nth-child(4){--degree:105deg}}.sm\:fab-flower:has(>:nth-child(5)){--position:180%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:135deg}&>:nth-child(5){--degree:90deg}}.sm\:fab-flower:has(>:nth-child(6)){--position:220%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:150deg}&>:nth-child(5){--degree:120deg}&>:nth-child(6){--degree:90deg}}.sm\:fab-flower:not(:has(.fab-main-action,.fab-close))>:nth-child(n+6){display:none}.sm\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(2)){--position:140%;&>:nth-child(2){--degree:135deg}}.sm\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(3)){--position:140%;&>:nth-child(2){--degree:165deg}&>:nth-child(3){--degree:105deg}}.sm\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(4)){--position:180%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:135deg}&>:nth-child(4){--degree:90deg}}.sm\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(5)){--position:220%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:150deg}&>:nth-child(4){--degree:120deg}&>:nth-child(5){--degree:90deg}}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:fab{pointer-events:none;inset-inline-end:1rem;z-index:999;white-space:nowrap;-webkit-user-select:none;user-select:none;flex-direction:column-reverse;align-items:flex-end;gap:.5rem;font-size:.875rem;line-height:1.25rem;display:flex;position:fixed;bottom:1rem}.md\:fab>*{pointer-events:auto;align-items:center;gap:.5rem;display:flex;&:hover,&:has(:focus-visible){z-index:1}}.md\:fab>[tabindex]:first-child{transition-property:opacity,visibility,rotate;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:grid;position:relative}.md\:fab .fab-close,.md\:fab .fab-main-action{inset-inline-end:0;position:absolute;bottom:0}:is(.fab:focus-within:has(.fab-close),.fab:focus-within:has(.fab-main-action))>[tabindex]{opacity:0;rotate:90deg}.md\:fab>:nth-child(n+2){visibility:hidden;--tw-scale-x:80%;--tw-scale-y:80%;--tw-scale-z:80%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:0;transition-property:opacity,scale,visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);&.fab-main-action,&.fab-close{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.md\:fab>:nth-child(3){transition-delay:30ms}.md\:fab>:nth-child(4){transition-delay:60ms}.md\:fab>:nth-child(5){transition-delay:90ms}.md\:fab>:nth-child(6){transition-delay:.12s}.md\:fab:focus-within>[tabindex]:first-child{pointer-events:none}.md\:fab:focus-within>:nth-child(n+2){visibility:visible;--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}.md\:fab-flower{--position:0rem;display:grid}.md\:fab-flower>:first-child,.md\:fab-flower>.fab-main-action,.md\:fab-flower>.fab-close{--position:0rem}.md\:fab-flower>*{--degree:180deg;--flip-degree:calc(180deg - var(--degree));transform:translateX(calc(cos(var(--degree)) * var(--position))) translateY(calc(sin(var(--degree)) * -1 * var(--position)));grid-area:1/1;[dir=rtl] &{transform:translateX(calc(cos(var(--flip-degree)) * var(--position))) translateY(calc(sin(var(--flip-degree)) * -1 * var(--position)))}}.md\:fab-flower>:nth-child(n+7){display:none}.md\:fab-flower:has(>:nth-child(3)){--position:140%;&>:nth-child(3){--degree:135deg}}.md\:fab-flower:has(>:nth-child(4)){--position:140%;&>:nth-child(3){--degree:165deg}&>:nth-child(4){--degree:105deg}}.md\:fab-flower:has(>:nth-child(5)){--position:180%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:135deg}&>:nth-child(5){--degree:90deg}}.md\:fab-flower:has(>:nth-child(6)){--position:220%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:150deg}&>:nth-child(5){--degree:120deg}&>:nth-child(6){--degree:90deg}}.md\:fab-flower:not(:has(.fab-main-action,.fab-close))>:nth-child(n+6){display:none}.md\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(2)){--position:140%;&>:nth-child(2){--degree:135deg}}.md\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(3)){--position:140%;&>:nth-child(2){--degree:165deg}&>:nth-child(3){--degree:105deg}}.md\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(4)){--position:180%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:135deg}&>:nth-child(4){--degree:90deg}}.md\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(5)){--position:220%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:150deg}&>:nth-child(4){--degree:120deg}&>:nth-child(5){--degree:90deg}}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:fab{pointer-events:none;inset-inline-end:1rem;z-index:999;white-space:nowrap;-webkit-user-select:none;user-select:none;flex-direction:column-reverse;align-items:flex-end;gap:.5rem;font-size:.875rem;line-height:1.25rem;display:flex;position:fixed;bottom:1rem}.lg\:fab>*{pointer-events:auto;align-items:center;gap:.5rem;display:flex;&:hover,&:has(:focus-visible){z-index:1}}.lg\:fab>[tabindex]:first-child{transition-property:opacity,visibility,rotate;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:grid;position:relative}.lg\:fab .fab-close,.lg\:fab .fab-main-action{inset-inline-end:0;position:absolute;bottom:0}:is(.fab:focus-within:has(.fab-close),.fab:focus-within:has(.fab-main-action))>[tabindex]{opacity:0;rotate:90deg}.lg\:fab>:nth-child(n+2){visibility:hidden;--tw-scale-x:80%;--tw-scale-y:80%;--tw-scale-z:80%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:0;transition-property:opacity,scale,visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);&.fab-main-action,&.fab-close{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.lg\:fab>:nth-child(3){transition-delay:30ms}.lg\:fab>:nth-child(4){transition-delay:60ms}.lg\:fab>:nth-child(5){transition-delay:90ms}.lg\:fab>:nth-child(6){transition-delay:.12s}.lg\:fab:focus-within>[tabindex]:first-child{pointer-events:none}.lg\:fab:focus-within>:nth-child(n+2){visibility:visible;--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}.lg\:fab-flower{--position:0rem;display:grid}.lg\:fab-flower>:first-child,.lg\:fab-flower>.fab-main-action,.lg\:fab-flower>.fab-close{--position:0rem}.lg\:fab-flower>*{--degree:180deg;--flip-degree:calc(180deg - var(--degree));transform:translateX(calc(cos(var(--degree)) * var(--position))) translateY(calc(sin(var(--degree)) * -1 * var(--position)));grid-area:1/1;[dir=rtl] &{transform:translateX(calc(cos(var(--flip-degree)) * var(--position))) translateY(calc(sin(var(--flip-degree)) * -1 * var(--position)))}}.lg\:fab-flower>:nth-child(n+7){display:none}.lg\:fab-flower:has(>:nth-child(3)){--position:140%;&>:nth-child(3){--degree:135deg}}.lg\:fab-flower:has(>:nth-child(4)){--position:140%;&>:nth-child(3){--degree:165deg}&>:nth-child(4){--degree:105deg}}.lg\:fab-flower:has(>:nth-child(5)){--position:180%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:135deg}&>:nth-child(5){--degree:90deg}}.lg\:fab-flower:has(>:nth-child(6)){--position:220%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:150deg}&>:nth-child(5){--degree:120deg}&>:nth-child(6){--degree:90deg}}.lg\:fab-flower:not(:has(.fab-main-action,.fab-close))>:nth-child(n+6){display:none}.lg\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(2)){--position:140%;&>:nth-child(2){--degree:135deg}}.lg\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(3)){--position:140%;&>:nth-child(2){--degree:165deg}&>:nth-child(3){--degree:105deg}}.lg\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(4)){--position:180%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:135deg}&>:nth-child(4){--degree:90deg}}.lg\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(5)){--position:220%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:150deg}&>:nth-child(4){--degree:120deg}&>:nth-child(5){--degree:90deg}}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:fab{pointer-events:none;inset-inline-end:1rem;z-index:999;white-space:nowrap;-webkit-user-select:none;user-select:none;flex-direction:column-reverse;align-items:flex-end;gap:.5rem;font-size:.875rem;line-height:1.25rem;display:flex;position:fixed;bottom:1rem}.xl\:fab>*{pointer-events:auto;align-items:center;gap:.5rem;display:flex;&:hover,&:has(:focus-visible){z-index:1}}.xl\:fab>[tabindex]:first-child{transition-property:opacity,visibility,rotate;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:grid;position:relative}.xl\:fab .fab-close,.xl\:fab .fab-main-action{inset-inline-end:0;position:absolute;bottom:0}:is(.fab:focus-within:has(.fab-close),.fab:focus-within:has(.fab-main-action))>[tabindex]{opacity:0;rotate:90deg}.xl\:fab>:nth-child(n+2){visibility:hidden;--tw-scale-x:80%;--tw-scale-y:80%;--tw-scale-z:80%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:0;transition-property:opacity,scale,visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);&.fab-main-action,&.fab-close{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.xl\:fab>:nth-child(3){transition-delay:30ms}.xl\:fab>:nth-child(4){transition-delay:60ms}.xl\:fab>:nth-child(5){transition-delay:90ms}.xl\:fab>:nth-child(6){transition-delay:.12s}.xl\:fab:focus-within>[tabindex]:first-child{pointer-events:none}.xl\:fab:focus-within>:nth-child(n+2){visibility:visible;--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}.xl\:fab-flower{--position:0rem;display:grid}.xl\:fab-flower>:first-child,.xl\:fab-flower>.fab-main-action,.xl\:fab-flower>.fab-close{--position:0rem}.xl\:fab-flower>*{--degree:180deg;--flip-degree:calc(180deg - var(--degree));transform:translateX(calc(cos(var(--degree)) * var(--position))) translateY(calc(sin(var(--degree)) * -1 * var(--position)));grid-area:1/1;[dir=rtl] &{transform:translateX(calc(cos(var(--flip-degree)) * var(--position))) translateY(calc(sin(var(--flip-degree)) * -1 * var(--position)))}}.xl\:fab-flower>:nth-child(n+7){display:none}.xl\:fab-flower:has(>:nth-child(3)){--position:140%;&>:nth-child(3){--degree:135deg}}.xl\:fab-flower:has(>:nth-child(4)){--position:140%;&>:nth-child(3){--degree:165deg}&>:nth-child(4){--degree:105deg}}.xl\:fab-flower:has(>:nth-child(5)){--position:180%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:135deg}&>:nth-child(5){--degree:90deg}}.xl\:fab-flower:has(>:nth-child(6)){--position:220%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:150deg}&>:nth-child(5){--degree:120deg}&>:nth-child(6){--degree:90deg}}.xl\:fab-flower:not(:has(.fab-main-action,.fab-close))>:nth-child(n+6){display:none}.xl\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(2)){--position:140%;&>:nth-child(2){--degree:135deg}}.xl\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(3)){--position:140%;&>:nth-child(2){--degree:165deg}&>:nth-child(3){--degree:105deg}}.xl\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(4)){--position:180%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:135deg}&>:nth-child(4){--degree:90deg}}.xl\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(5)){--position:220%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:150deg}&>:nth-child(4){--degree:120deg}&>:nth-child(5){--degree:90deg}}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:fab{pointer-events:none;inset-inline-end:1rem;z-index:999;white-space:nowrap;-webkit-user-select:none;user-select:none;flex-direction:column-reverse;align-items:flex-end;gap:.5rem;font-size:.875rem;line-height:1.25rem;display:flex;position:fixed;bottom:1rem}.\32 xl\:fab>*{pointer-events:auto;align-items:center;gap:.5rem;display:flex;&:hover,&:has(:focus-visible){z-index:1}}.\32 xl\:fab>[tabindex]:first-child{transition-property:opacity,visibility,rotate;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:grid;position:relative}.\32 xl\:fab .fab-close,.\32 xl\:fab .fab-main-action{inset-inline-end:0;position:absolute;bottom:0}:is(.fab:focus-within:has(.fab-close),.fab:focus-within:has(.fab-main-action))>[tabindex]{opacity:0;rotate:90deg}.\32 xl\:fab>:nth-child(n+2){visibility:hidden;--tw-scale-x:80%;--tw-scale-y:80%;--tw-scale-z:80%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:0;transition-property:opacity,scale,visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);&.fab-main-action,&.fab-close{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}}.\32 xl\:fab>:nth-child(3){transition-delay:30ms}.\32 xl\:fab>:nth-child(4){transition-delay:60ms}.\32 xl\:fab>:nth-child(5){transition-delay:90ms}.\32 xl\:fab>:nth-child(6){transition-delay:.12s}.\32 xl\:fab:focus-within>[tabindex]:first-child{pointer-events:none}.\32 xl\:fab:focus-within>:nth-child(n+2){visibility:visible;--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y);opacity:1}.\32 xl\:fab-flower{--position:0rem;display:grid}.\32 xl\:fab-flower>:first-child,.\32 xl\:fab-flower>.fab-main-action,.\32 xl\:fab-flower>.fab-close{--position:0rem}.\32 xl\:fab-flower>*{--degree:180deg;--flip-degree:calc(180deg - var(--degree));transform:translateX(calc(cos(var(--degree)) * var(--position))) translateY(calc(sin(var(--degree)) * -1 * var(--position)));grid-area:1/1;[dir=rtl] &{transform:translateX(calc(cos(var(--flip-degree)) * var(--position))) translateY(calc(sin(var(--flip-degree)) * -1 * var(--position)))}}.\32 xl\:fab-flower>:nth-child(n+7){display:none}.\32 xl\:fab-flower:has(>:nth-child(3)){--position:140%;&>:nth-child(3){--degree:135deg}}.\32 xl\:fab-flower:has(>:nth-child(4)){--position:140%;&>:nth-child(3){--degree:165deg}&>:nth-child(4){--degree:105deg}}.\32 xl\:fab-flower:has(>:nth-child(5)){--position:180%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:135deg}&>:nth-child(5){--degree:90deg}}.\32 xl\:fab-flower:has(>:nth-child(6)){--position:220%;&>:nth-child(3){--degree:180deg}&>:nth-child(4){--degree:150deg}&>:nth-child(5){--degree:120deg}&>:nth-child(6){--degree:90deg}}.\32 xl\:fab-flower:not(:has(.fab-main-action,.fab-close))>:nth-child(n+6){display:none}.\32 xl\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(2)){--position:140%;&>:nth-child(2){--degree:135deg}}.\32 xl\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(3)){--position:140%;&>:nth-child(2){--degree:165deg}&>:nth-child(3){--degree:105deg}}.\32 xl\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(4)){--position:180%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:135deg}&>:nth-child(4){--degree:90deg}}.\32 xl\:fab-flower:not(:has(.fab-main-action,.fab-close)):has(>:nth-child(5)){--position:220%;&>:nth-child(2){--degree:180deg}&>:nth-child(3){--degree:150deg}&>:nth-child(4){--degree:120deg}&>:nth-child(5){--degree:90deg}}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.sm\:stack>*{width:100%;height:100%;&:nth-child(n+2){opacity:.7;width:100%}&:nth-child(2){z-index:2;opacity:.9}&:first-child{z-index:3;width:100%}}}@layer daisyui.l1.l2{:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/4/6}}.sm\:stack.stack-top>*{grid-area:1/3/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:3/1/6/6}}.sm\:stack.stack-start>*{grid-area:3/1/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/3/6/6}}.sm\:stack.stack-end>*{grid-area:3/3/4/6;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/6/4}}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.md\:stack>*{width:100%;height:100%;&:nth-child(n+2){opacity:.7;width:100%}&:nth-child(2){z-index:2;opacity:.9}&:first-child{z-index:3;width:100%}}}@layer daisyui.l1.l2{:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/4/6}}.md\:stack.stack-top>*{grid-area:1/3/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:3/1/6/6}}.md\:stack.stack-start>*{grid-area:3/1/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/3/6/6}}.md\:stack.stack-end>*{grid-area:3/3/4/6;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/6/4}}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.lg\:stack>*{width:100%;height:100%;&:nth-child(n+2){opacity:.7;width:100%}&:nth-child(2){z-index:2;opacity:.9}&:first-child{z-index:3;width:100%}}}@layer daisyui.l1.l2{:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/4/6}}.lg\:stack.stack-top>*{grid-area:1/3/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:3/1/6/6}}.lg\:stack.stack-start>*{grid-area:3/1/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/3/6/6}}.lg\:stack.stack-end>*{grid-area:3/3/4/6;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/6/4}}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.xl\:stack>*{width:100%;height:100%;&:nth-child(n+2){opacity:.7;width:100%}&:nth-child(2){z-index:2;opacity:.9}&:first-child{z-index:3;width:100%}}}@layer daisyui.l1.l2{:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/4/6}}.xl\:stack.stack-top>*{grid-area:1/3/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:3/1/6/6}}.xl\:stack.stack-start>*{grid-area:3/1/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/3/6/6}}.xl\:stack.stack-end>*{grid-area:3/3/4/6;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/6/4}}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:stack{grid-template-rows:3px 4px 1fr 4px 3px;grid-template-columns:3px 4px 1fr 4px 3px;display:inline-grid}.\32 xl\:stack>*{width:100%;height:100%;&:nth-child(n+2){opacity:.7;width:100%}&:nth-child(2){z-index:2;opacity:.9}&:first-child{z-index:3;width:100%}}}@layer daisyui.l1.l2{:is(.stack,.stack.stack-bottom)>*{grid-area:3/3/6/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/4/6}}.\32 xl\:stack.stack-top>*{grid-area:1/3/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:3/1/6/6}}.\32 xl\:stack.stack-start>*{grid-area:3/1/4/4;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/3/6/6}}.\32 xl\:stack.stack-end>*{grid-area:3/3/4/6;&:nth-child(2){grid-area:2/2/5/5}&:first-child{grid-area:1/1/6/4}}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:rating{vertical-align:middle;--size:var(--size-selector,.25rem) * 6;display:inline-flex;position:relative}.sm\:rating input{cursor:pointer;appearance:none}.sm\:rating *{background-color:var(--color-base-content);opacity:.2;width:calc(var(--size) * 1);height:calc(var(--size));border-radius:0;@media (prefers-reduced-motion:no-preference){animation:.25s ease-out rating}}.sm\:rating .rating-hidden{background-color:#0000;width:.5rem}.sm\:rating :checked,.sm\:rating [aria-checked=true],.sm\:rating [aria-current=true],.sm\:rating :has(~:checked,~[aria-checked=true],~[aria-current=true]){opacity:1}.sm\:rating :focus-visible{scale:1.1;@media (prefers-reduced-motion:no-preference){transition:scale .2s ease-out}}.sm\:rating :active:focus{animation:none;scale:1.1}}@layer daisyui.l1.l2{.sm\:rating-half *{width:calc(var(--size) * .5)}.sm\:rating-xs{--size:var(--size-selector,.25rem) * 4}.sm\:rating-sm{--size:var(--size-selector,.25rem) * 5}.sm\:rating-md{--size:var(--size-selector,.25rem) * 6}.sm\:rating-lg{--size:var(--size-selector,.25rem) * 7}.sm\:rating-xl{--size:var(--size-selector,.25rem) * 8}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:rating{vertical-align:middle;--size:var(--size-selector,.25rem) * 6;display:inline-flex;position:relative}.md\:rating input{cursor:pointer;appearance:none}.md\:rating *{background-color:var(--color-base-content);opacity:.2;width:calc(var(--size) * 1);height:calc(var(--size));border-radius:0;@media (prefers-reduced-motion:no-preference){animation:.25s ease-out rating}}.md\:rating .rating-hidden{background-color:#0000;width:.5rem}.md\:rating :checked,.md\:rating [aria-checked=true],.md\:rating [aria-current=true],.md\:rating :has(~:checked,~[aria-checked=true],~[aria-current=true]){opacity:1}.md\:rating :focus-visible{scale:1.1;@media (prefers-reduced-motion:no-preference){transition:scale .2s ease-out}}.md\:rating :active:focus{animation:none;scale:1.1}}@layer daisyui.l1.l2{.md\:rating-half *{width:calc(var(--size) * .5)}.md\:rating-xs{--size:var(--size-selector,.25rem) * 4}.md\:rating-sm{--size:var(--size-selector,.25rem) * 5}.md\:rating-md{--size:var(--size-selector,.25rem) * 6}.md\:rating-lg{--size:var(--size-selector,.25rem) * 7}.md\:rating-xl{--size:var(--size-selector,.25rem) * 8}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:rating{vertical-align:middle;--size:var(--size-selector,.25rem) * 6;display:inline-flex;position:relative}.lg\:rating input{cursor:pointer;appearance:none}.lg\:rating *{background-color:var(--color-base-content);opacity:.2;width:calc(var(--size) * 1);height:calc(var(--size));border-radius:0;@media (prefers-reduced-motion:no-preference){animation:.25s ease-out rating}}.lg\:rating .rating-hidden{background-color:#0000;width:.5rem}.lg\:rating :checked,.lg\:rating [aria-checked=true],.lg\:rating [aria-current=true],.lg\:rating :has(~:checked,~[aria-checked=true],~[aria-current=true]){opacity:1}.lg\:rating :focus-visible{scale:1.1;@media (prefers-reduced-motion:no-preference){transition:scale .2s ease-out}}.lg\:rating :active:focus{animation:none;scale:1.1}}@layer daisyui.l1.l2{.lg\:rating-half *{width:calc(var(--size) * .5)}.lg\:rating-xs{--size:var(--size-selector,.25rem) * 4}.lg\:rating-sm{--size:var(--size-selector,.25rem) * 5}.lg\:rating-md{--size:var(--size-selector,.25rem) * 6}.lg\:rating-lg{--size:var(--size-selector,.25rem) * 7}.lg\:rating-xl{--size:var(--size-selector,.25rem) * 8}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:rating{vertical-align:middle;--size:var(--size-selector,.25rem) * 6;display:inline-flex;position:relative}.xl\:rating input{cursor:pointer;appearance:none}.xl\:rating *{background-color:var(--color-base-content);opacity:.2;width:calc(var(--size) * 1);height:calc(var(--size));border-radius:0;@media (prefers-reduced-motion:no-preference){animation:.25s ease-out rating}}.xl\:rating .rating-hidden{background-color:#0000;width:.5rem}.xl\:rating :checked,.xl\:rating [aria-checked=true],.xl\:rating [aria-current=true],.xl\:rating :has(~:checked,~[aria-checked=true],~[aria-current=true]){opacity:1}.xl\:rating :focus-visible{scale:1.1;@media (prefers-reduced-motion:no-preference){transition:scale .2s ease-out}}.xl\:rating :active:focus{animation:none;scale:1.1}}@layer daisyui.l1.l2{.xl\:rating-half *{width:calc(var(--size) * .5)}.xl\:rating-xs{--size:var(--size-selector,.25rem) * 4}.xl\:rating-sm{--size:var(--size-selector,.25rem) * 5}.xl\:rating-md{--size:var(--size-selector,.25rem) * 6}.xl\:rating-lg{--size:var(--size-selector,.25rem) * 7}.xl\:rating-xl{--size:var(--size-selector,.25rem) * 8}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:rating{vertical-align:middle;--size:var(--size-selector,.25rem) * 6;display:inline-flex;position:relative}.\32 xl\:rating input{cursor:pointer;appearance:none}.\32 xl\:rating *{background-color:var(--color-base-content);opacity:.2;width:calc(var(--size) * 1);height:calc(var(--size));border-radius:0;@media (prefers-reduced-motion:no-preference){animation:.25s ease-out rating}}.\32 xl\:rating .rating-hidden{background-color:#0000;width:.5rem}.\32 xl\:rating :checked,.\32 xl\:rating [aria-checked=true],.\32 xl\:rating [aria-current=true],.\32 xl\:rating :has(~:checked,~[aria-checked=true],~[aria-current=true]){opacity:1}.\32 xl\:rating :focus-visible{scale:1.1;@media (prefers-reduced-motion:no-preference){transition:scale .2s ease-out}}.\32 xl\:rating :active:focus{animation:none;scale:1.1}}@layer daisyui.l1.l2{.\32 xl\:rating-half *{width:calc(var(--size) * .5)}.\32 xl\:rating-xs{--size:var(--size-selector,.25rem) * 4}.\32 xl\:rating-sm{--size:var(--size-selector,.25rem) * 5}.\32 xl\:rating-md{--size:var(--size-selector,.25rem) * 6}.\32 xl\:rating-lg{--size:var(--size-selector,.25rem) * 7}.\32 xl\:rating-xl{--size:var(--size-selector,.25rem) * 8}}}@keyframes rating{0%,40%{filter:brightness(1.05)contrast(1.05);scale:1.1}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:aura{--aura-padding:.125rem;padding:var(--aura-padding);border-radius:calc(var(--aura-padding) + var(--aura-radius,var(--radius-box)));animation:aura var(--tw-duration,6s) linear infinite;display:inline-block;position:relative}@media (prefers-reduced-motion:reduce){.sm\:aura{animation-duration:calc(var(--tw-duration,6s) * 4)}}.sm\:aura{background-image:conic-gradient(from var(--aura-angle), transparent 225deg, currentColor)}.sm\:aura:has(>.card,>.alert){--aura-radius:var(--radius-box)}.sm\:aura:has(>.btn,>.input,>.select){--aura-radius:var(--radius-field)}.sm\:aura:has(>.checkbox,>.toggle,>.badge){--aura-radius:var(--radius-selector)}.sm\:aura:before,.sm\:aura:after{animation:inherit;background-color:inherit;background-image:inherit;border-radius:inherit;z-index:0;opacity:.7;filter:blur(.25rem);content:"";width:100%;height:100%;display:block;position:absolute;top:50%;left:50%;translate:-50% -50%}.sm\:aura:after{opacity:.3;filter:blur(1rem)}.sm\:aura>*{z-index:1;position:relative}}@layer daisyui.l1.l2{.sm\:aura-rainbow{background:conic-gradient(from var(--aura-angle) in oklch longer hue, transparent 10%, oklch(80% .15 0), oklch(80% .15 360), transparent 90%)}.sm\:aura-holo{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(82% .17 327), oklch(75% .12 274), oklch(82% .11 191), oklch(91% .11 105), oklch(88% .08 68), oklch(82% .17 327) 10%);animation:aura var(--tw-duration,20s) linear infinite}@media (prefers-reduced-motion:reduce){.sm\:aura-holo{animation-duration:calc(var(--tw-duration,20s) * 4)}}.sm\:aura-dual{background-image:repeating-conic-gradient(from var(--aura-angle), transparent 0%, transparent 40%, currentColor 50%)}.sm\:aura-silver{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(30% 0 0), oklch(90% 0 0), oklch(60% 0 0), oklch(90% 0 0), oklch(50% 0 0), oklch(30% 0 0) 50%)}.sm\:aura-gold{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(65.98% .1863 72.37), oklch(96.35% .0768 102.94), oklch(71.57% .1691 82.23), oklch(96.02% .0792 103.13), oklch(60.66% .1181 76.17), oklch(65.98% .1863 72.37) 50%)}.sm\:aura-glow{background-image:radial-gradient(closest-corner,currentColor 0%,#0000 90%);animation:none}.sm\:aura-glow:before{animation:aura-glow var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.sm\:aura-glow:after{animation:aura-glow-after var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.sm\:aura-xs{--aura-padding:0rem}.sm\:aura-sm{--aura-padding:.0625rem}.sm\:aura-md{--aura-padding:.125rem}.sm\:aura-lg{--aura-padding:.15625rem}.sm\:aura-xl{--aura-padding:.25rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:aura{--aura-padding:.125rem;padding:var(--aura-padding);border-radius:calc(var(--aura-padding) + var(--aura-radius,var(--radius-box)));animation:aura var(--tw-duration,6s) linear infinite;display:inline-block;position:relative}@media (prefers-reduced-motion:reduce){.md\:aura{animation-duration:calc(var(--tw-duration,6s) * 4)}}.md\:aura{background-image:conic-gradient(from var(--aura-angle), transparent 225deg, currentColor)}.md\:aura:has(>.card,>.alert){--aura-radius:var(--radius-box)}.md\:aura:has(>.btn,>.input,>.select){--aura-radius:var(--radius-field)}.md\:aura:has(>.checkbox,>.toggle,>.badge){--aura-radius:var(--radius-selector)}.md\:aura:before,.md\:aura:after{animation:inherit;background-color:inherit;background-image:inherit;border-radius:inherit;z-index:0;opacity:.7;filter:blur(.25rem);content:"";width:100%;height:100%;display:block;position:absolute;top:50%;left:50%;translate:-50% -50%}.md\:aura:after{opacity:.3;filter:blur(1rem)}.md\:aura>*{z-index:1;position:relative}}@layer daisyui.l1.l2{.md\:aura-rainbow{background:conic-gradient(from var(--aura-angle) in oklch longer hue, transparent 10%, oklch(80% .15 0), oklch(80% .15 360), transparent 90%)}.md\:aura-holo{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(82% .17 327), oklch(75% .12 274), oklch(82% .11 191), oklch(91% .11 105), oklch(88% .08 68), oklch(82% .17 327) 10%);animation:aura var(--tw-duration,20s) linear infinite}@media (prefers-reduced-motion:reduce){.md\:aura-holo{animation-duration:calc(var(--tw-duration,20s) * 4)}}.md\:aura-dual{background-image:repeating-conic-gradient(from var(--aura-angle), transparent 0%, transparent 40%, currentColor 50%)}.md\:aura-silver{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(30% 0 0), oklch(90% 0 0), oklch(60% 0 0), oklch(90% 0 0), oklch(50% 0 0), oklch(30% 0 0) 50%)}.md\:aura-gold{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(65.98% .1863 72.37), oklch(96.35% .0768 102.94), oklch(71.57% .1691 82.23), oklch(96.02% .0792 103.13), oklch(60.66% .1181 76.17), oklch(65.98% .1863 72.37) 50%)}.md\:aura-glow{background-image:radial-gradient(closest-corner,currentColor 0%,#0000 90%);animation:none}.md\:aura-glow:before{animation:aura-glow var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.md\:aura-glow:after{animation:aura-glow-after var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.md\:aura-xs{--aura-padding:0rem}.md\:aura-sm{--aura-padding:.0625rem}.md\:aura-md{--aura-padding:.125rem}.md\:aura-lg{--aura-padding:.15625rem}.md\:aura-xl{--aura-padding:.25rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:aura{--aura-padding:.125rem;padding:var(--aura-padding);border-radius:calc(var(--aura-padding) + var(--aura-radius,var(--radius-box)));animation:aura var(--tw-duration,6s) linear infinite;display:inline-block;position:relative}@media (prefers-reduced-motion:reduce){.lg\:aura{animation-duration:calc(var(--tw-duration,6s) * 4)}}.lg\:aura{background-image:conic-gradient(from var(--aura-angle), transparent 225deg, currentColor)}.lg\:aura:has(>.card,>.alert){--aura-radius:var(--radius-box)}.lg\:aura:has(>.btn,>.input,>.select){--aura-radius:var(--radius-field)}.lg\:aura:has(>.checkbox,>.toggle,>.badge){--aura-radius:var(--radius-selector)}.lg\:aura:before,.lg\:aura:after{animation:inherit;background-color:inherit;background-image:inherit;border-radius:inherit;z-index:0;opacity:.7;filter:blur(.25rem);content:"";width:100%;height:100%;display:block;position:absolute;top:50%;left:50%;translate:-50% -50%}.lg\:aura:after{opacity:.3;filter:blur(1rem)}.lg\:aura>*{z-index:1;position:relative}}@layer daisyui.l1.l2{.lg\:aura-rainbow{background:conic-gradient(from var(--aura-angle) in oklch longer hue, transparent 10%, oklch(80% .15 0), oklch(80% .15 360), transparent 90%)}.lg\:aura-holo{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(82% .17 327), oklch(75% .12 274), oklch(82% .11 191), oklch(91% .11 105), oklch(88% .08 68), oklch(82% .17 327) 10%);animation:aura var(--tw-duration,20s) linear infinite}@media (prefers-reduced-motion:reduce){.lg\:aura-holo{animation-duration:calc(var(--tw-duration,20s) * 4)}}.lg\:aura-dual{background-image:repeating-conic-gradient(from var(--aura-angle), transparent 0%, transparent 40%, currentColor 50%)}.lg\:aura-silver{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(30% 0 0), oklch(90% 0 0), oklch(60% 0 0), oklch(90% 0 0), oklch(50% 0 0), oklch(30% 0 0) 50%)}.lg\:aura-gold{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(65.98% .1863 72.37), oklch(96.35% .0768 102.94), oklch(71.57% .1691 82.23), oklch(96.02% .0792 103.13), oklch(60.66% .1181 76.17), oklch(65.98% .1863 72.37) 50%)}.lg\:aura-glow{background-image:radial-gradient(closest-corner,currentColor 0%,#0000 90%);animation:none}.lg\:aura-glow:before{animation:aura-glow var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.lg\:aura-glow:after{animation:aura-glow-after var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.lg\:aura-xs{--aura-padding:0rem}.lg\:aura-sm{--aura-padding:.0625rem}.lg\:aura-md{--aura-padding:.125rem}.lg\:aura-lg{--aura-padding:.15625rem}.lg\:aura-xl{--aura-padding:.25rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:aura{--aura-padding:.125rem;padding:var(--aura-padding);border-radius:calc(var(--aura-padding) + var(--aura-radius,var(--radius-box)));animation:aura var(--tw-duration,6s) linear infinite;display:inline-block;position:relative}@media (prefers-reduced-motion:reduce){.xl\:aura{animation-duration:calc(var(--tw-duration,6s) * 4)}}.xl\:aura{background-image:conic-gradient(from var(--aura-angle), transparent 225deg, currentColor)}.xl\:aura:has(>.card,>.alert){--aura-radius:var(--radius-box)}.xl\:aura:has(>.btn,>.input,>.select){--aura-radius:var(--radius-field)}.xl\:aura:has(>.checkbox,>.toggle,>.badge){--aura-radius:var(--radius-selector)}.xl\:aura:before,.xl\:aura:after{animation:inherit;background-color:inherit;background-image:inherit;border-radius:inherit;z-index:0;opacity:.7;filter:blur(.25rem);content:"";width:100%;height:100%;display:block;position:absolute;top:50%;left:50%;translate:-50% -50%}.xl\:aura:after{opacity:.3;filter:blur(1rem)}.xl\:aura>*{z-index:1;position:relative}}@layer daisyui.l1.l2{.xl\:aura-rainbow{background:conic-gradient(from var(--aura-angle) in oklch longer hue, transparent 10%, oklch(80% .15 0), oklch(80% .15 360), transparent 90%)}.xl\:aura-holo{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(82% .17 327), oklch(75% .12 274), oklch(82% .11 191), oklch(91% .11 105), oklch(88% .08 68), oklch(82% .17 327) 10%);animation:aura var(--tw-duration,20s) linear infinite}@media (prefers-reduced-motion:reduce){.xl\:aura-holo{animation-duration:calc(var(--tw-duration,20s) * 4)}}.xl\:aura-dual{background-image:repeating-conic-gradient(from var(--aura-angle), transparent 0%, transparent 40%, currentColor 50%)}.xl\:aura-silver{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(30% 0 0), oklch(90% 0 0), oklch(60% 0 0), oklch(90% 0 0), oklch(50% 0 0), oklch(30% 0 0) 50%)}.xl\:aura-gold{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(65.98% .1863 72.37), oklch(96.35% .0768 102.94), oklch(71.57% .1691 82.23), oklch(96.02% .0792 103.13), oklch(60.66% .1181 76.17), oklch(65.98% .1863 72.37) 50%)}.xl\:aura-glow{background-image:radial-gradient(closest-corner,currentColor 0%,#0000 90%);animation:none}.xl\:aura-glow:before{animation:aura-glow var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.xl\:aura-glow:after{animation:aura-glow-after var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.xl\:aura-xs{--aura-padding:0rem}.xl\:aura-sm{--aura-padding:.0625rem}.xl\:aura-md{--aura-padding:.125rem}.xl\:aura-lg{--aura-padding:.15625rem}.xl\:aura-xl{--aura-padding:.25rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:aura{--aura-padding:.125rem;padding:var(--aura-padding);border-radius:calc(var(--aura-padding) + var(--aura-radius,var(--radius-box)));animation:aura var(--tw-duration,6s) linear infinite;display:inline-block;position:relative}@media (prefers-reduced-motion:reduce){.\32 xl\:aura{animation-duration:calc(var(--tw-duration,6s) * 4)}}.\32 xl\:aura{background-image:conic-gradient(from var(--aura-angle), transparent 225deg, currentColor)}.\32 xl\:aura:has(>.card,>.alert){--aura-radius:var(--radius-box)}.\32 xl\:aura:has(>.btn,>.input,>.select){--aura-radius:var(--radius-field)}.\32 xl\:aura:has(>.checkbox,>.toggle,>.badge){--aura-radius:var(--radius-selector)}.\32 xl\:aura:before,.\32 xl\:aura:after{animation:inherit;background-color:inherit;background-image:inherit;border-radius:inherit;z-index:0;opacity:.7;filter:blur(.25rem);content:"";width:100%;height:100%;display:block;position:absolute;top:50%;left:50%;translate:-50% -50%}.\32 xl\:aura:after{opacity:.3;filter:blur(1rem)}.\32 xl\:aura>*{z-index:1;position:relative}}@layer daisyui.l1.l2{.\32 xl\:aura-rainbow{background:conic-gradient(from var(--aura-angle) in oklch longer hue, transparent 10%, oklch(80% .15 0), oklch(80% .15 360), transparent 90%)}.\32 xl\:aura-holo{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(82% .17 327), oklch(75% .12 274), oklch(82% .11 191), oklch(91% .11 105), oklch(88% .08 68), oklch(82% .17 327) 10%);animation:aura var(--tw-duration,20s) linear infinite}@media (prefers-reduced-motion:reduce){.\32 xl\:aura-holo{animation-duration:calc(var(--tw-duration,20s) * 4)}}.\32 xl\:aura-dual{background-image:repeating-conic-gradient(from var(--aura-angle), transparent 0%, transparent 40%, currentColor 50%)}.\32 xl\:aura-silver{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(30% 0 0), oklch(90% 0 0), oklch(60% 0 0), oklch(90% 0 0), oklch(50% 0 0), oklch(30% 0 0) 50%)}.\32 xl\:aura-gold{background-image:repeating-conic-gradient(from var(--aura-angle), oklch(65.98% .1863 72.37), oklch(96.35% .0768 102.94), oklch(71.57% .1691 82.23), oklch(96.02% .0792 103.13), oklch(60.66% .1181 76.17), oklch(65.98% .1863 72.37) 50%)}.\32 xl\:aura-glow{background-image:radial-gradient(closest-corner,currentColor 0%,#0000 90%);animation:none}.\32 xl\:aura-glow:before{animation:aura-glow var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.\32 xl\:aura-glow:after{animation:aura-glow-after var(--tw-duration,6s) ease-out infinite;@media (prefers-reduced-motion:reduce){animation-duration:calc(var(--tw-duration,6s) * 4)}}.\32 xl\:aura-xs{--aura-padding:0rem}.\32 xl\:aura-sm{--aura-padding:.0625rem}.\32 xl\:aura-md{--aura-padding:.125rem}.\32 xl\:aura-lg{--aura-padding:.15625rem}.\32 xl\:aura-xl{--aura-padding:.25rem}}}@keyframes aura{to{--aura-angle:360deg;transform:translateZ(1px)}}@keyframes aura-glow{20%,80%{opacity:.7;filter:blur(.25rem)}50%{opacity:1;filter:blur(.75rem)}}@keyframes aura-glow-after{20%,80%{opacity:.3;filter:blur(1rem)}50%{opacity:.6;filter:blur(1.5rem)}}@media (width>=640px){@layer daisyui.l1.l2{.sm\:diff{-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;align-items:normal;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.sm\:diff:focus-visible,.sm\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.sm\:diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content);& .diff-resizer{min-width:95cqi;max-width:95cqi}}.sm\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;& .diff-resizer{min-width:5cqi;max-width:5cqi}}.sm\:diff:hover .diff-item-2:after{height:2.4rem}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.sm\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.sm\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.sm\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.sm\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}}}@layer daisyui.l1.l2.l3{.sm\:diff-resizer{isolation:isolate;z-index:2;resize:horizontal;opacity:0;cursor:ew-resize;transform-origin:100% 100%;clip-path:inset(calc(100% - .75rem) 0 0 calc(100% - .75rem));grid-row-start:2;grid-column-start:1;width:50cqi;min-width:1rem;max-width:calc(100cqi - 1rem);height:.75rem;transition:min-width .3s ease-out,max-width .3s ease-out;position:relative;overflow:hidden;transform:scaleY(5)translate(.32rem,50%)}.sm\:diff-item-2{grid-row:1/span 3;grid-column-start:1;position:relative}.sm\:diff-item-2:after{pointer-events:none;z-index:2;background-color:color-mix(in oklab, var(--color-base-100) 98%, transparent);border:2px solid var(--color-base-100);content:"";outline:2px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);outline-offset:-3px;width:1.2rem;height:1.8rem;transition:height .3s linear(0, .931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);border-radius:3.40282e38px;position:absolute;top:50%;bottom:0;right:1px;translate:50% -50%;box-shadow:0 0 0 2px #0000002a}.sm\:diff-item-2>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.sm\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.sm\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}}.sm\:diff-item-1{z-index:1;border-right:2px solid var(--color-base-100);grid-row:1/span 3;grid-column-start:1;position:relative;overflow:hidden;box-shadow:0 0 0 2px #0000002a}.sm\:diff-item-1:focus-visible{--tw-outline-style:none;outline-style:none}.sm\:diff-item-1>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}}}@media (width>=768px){@layer daisyui.l1.l2{.md\:diff{-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;align-items:normal;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.md\:diff:focus-visible,.md\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.md\:diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content);& .diff-resizer{min-width:95cqi;max-width:95cqi}}.md\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;& .diff-resizer{min-width:5cqi;max-width:5cqi}}.md\:diff:hover .diff-item-2:after{height:2.4rem}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.md\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.md\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.md\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.md\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}}}@layer daisyui.l1.l2.l3{.md\:diff-resizer{isolation:isolate;z-index:2;resize:horizontal;opacity:0;cursor:ew-resize;transform-origin:100% 100%;clip-path:inset(calc(100% - .75rem) 0 0 calc(100% - .75rem));grid-row-start:2;grid-column-start:1;width:50cqi;min-width:1rem;max-width:calc(100cqi - 1rem);height:.75rem;transition:min-width .3s ease-out,max-width .3s ease-out;position:relative;overflow:hidden;transform:scaleY(5)translate(.32rem,50%)}.md\:diff-item-2{grid-row:1/span 3;grid-column-start:1;position:relative}.md\:diff-item-2:after{pointer-events:none;z-index:2;background-color:color-mix(in oklab, var(--color-base-100) 98%, transparent);border:2px solid var(--color-base-100);content:"";outline:2px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);outline-offset:-3px;width:1.2rem;height:1.8rem;transition:height .3s linear(0, .931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);border-radius:3.40282e38px;position:absolute;top:50%;bottom:0;right:1px;translate:50% -50%;box-shadow:0 0 0 2px #0000002a}.md\:diff-item-2>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.md\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.md\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}}.md\:diff-item-1{z-index:1;border-right:2px solid var(--color-base-100);grid-row:1/span 3;grid-column-start:1;position:relative;overflow:hidden;box-shadow:0 0 0 2px #0000002a}.md\:diff-item-1:focus-visible{--tw-outline-style:none;outline-style:none}.md\:diff-item-1>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}}}@media (width>=1024px){@layer daisyui.l1.l2{.lg\:diff{-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;align-items:normal;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.lg\:diff:focus-visible,.lg\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.lg\:diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content);& .diff-resizer{min-width:95cqi;max-width:95cqi}}.lg\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;& .diff-resizer{min-width:5cqi;max-width:5cqi}}.lg\:diff:hover .diff-item-2:after{height:2.4rem}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.lg\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.lg\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.lg\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.lg\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}}}@layer daisyui.l1.l2.l3{.lg\:diff-resizer{isolation:isolate;z-index:2;resize:horizontal;opacity:0;cursor:ew-resize;transform-origin:100% 100%;clip-path:inset(calc(100% - .75rem) 0 0 calc(100% - .75rem));grid-row-start:2;grid-column-start:1;width:50cqi;min-width:1rem;max-width:calc(100cqi - 1rem);height:.75rem;transition:min-width .3s ease-out,max-width .3s ease-out;position:relative;overflow:hidden;transform:scaleY(5)translate(.32rem,50%)}.lg\:diff-item-2{grid-row:1/span 3;grid-column-start:1;position:relative}.lg\:diff-item-2:after{pointer-events:none;z-index:2;background-color:color-mix(in oklab, var(--color-base-100) 98%, transparent);border:2px solid var(--color-base-100);content:"";outline:2px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);outline-offset:-3px;width:1.2rem;height:1.8rem;transition:height .3s linear(0, .931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);border-radius:3.40282e38px;position:absolute;top:50%;bottom:0;right:1px;translate:50% -50%;box-shadow:0 0 0 2px #0000002a}.lg\:diff-item-2>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.lg\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.lg\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}}.lg\:diff-item-1{z-index:1;border-right:2px solid var(--color-base-100);grid-row:1/span 3;grid-column-start:1;position:relative;overflow:hidden;box-shadow:0 0 0 2px #0000002a}.lg\:diff-item-1:focus-visible{--tw-outline-style:none;outline-style:none}.lg\:diff-item-1>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}}}@media (width>=1280px){@layer daisyui.l1.l2{.xl\:diff{-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;align-items:normal;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.xl\:diff:focus-visible,.xl\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.xl\:diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content);& .diff-resizer{min-width:95cqi;max-width:95cqi}}.xl\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;& .diff-resizer{min-width:5cqi;max-width:5cqi}}.xl\:diff:hover .diff-item-2:after{height:2.4rem}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.xl\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.xl\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.xl\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.xl\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}}}@layer daisyui.l1.l2.l3{.xl\:diff-resizer{isolation:isolate;z-index:2;resize:horizontal;opacity:0;cursor:ew-resize;transform-origin:100% 100%;clip-path:inset(calc(100% - .75rem) 0 0 calc(100% - .75rem));grid-row-start:2;grid-column-start:1;width:50cqi;min-width:1rem;max-width:calc(100cqi - 1rem);height:.75rem;transition:min-width .3s ease-out,max-width .3s ease-out;position:relative;overflow:hidden;transform:scaleY(5)translate(.32rem,50%)}.xl\:diff-item-2{grid-row:1/span 3;grid-column-start:1;position:relative}.xl\:diff-item-2:after{pointer-events:none;z-index:2;background-color:color-mix(in oklab, var(--color-base-100) 98%, transparent);border:2px solid var(--color-base-100);content:"";outline:2px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);outline-offset:-3px;width:1.2rem;height:1.8rem;transition:height .3s linear(0, .931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);border-radius:3.40282e38px;position:absolute;top:50%;bottom:0;right:1px;translate:50% -50%;box-shadow:0 0 0 2px #0000002a}.xl\:diff-item-2>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.xl\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.xl\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}}.xl\:diff-item-1{z-index:1;border-right:2px solid var(--color-base-100);grid-row:1/span 3;grid-column-start:1;position:relative;overflow:hidden;box-shadow:0 0 0 2px #0000002a}.xl\:diff-item-1:focus-visible{--tw-outline-style:none;outline-style:none}.xl\:diff-item-1>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}}}@media (width>=1536px){@layer daisyui.l1.l2{.\32 xl\:diff{-webkit-user-select:none;user-select:none;direction:ltr;grid-template-rows:1fr 1.8rem 1fr;grid-template-columns:auto 1fr;align-items:normal;width:100%;display:grid;position:relative;overflow:hidden;container-type:inline-size}.\32 xl\:diff:focus-visible,.\32 xl\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content)}.\32 xl\:diff:focus-visible{outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;outline-color:var(--color-base-content);& .diff-resizer{min-width:95cqi;max-width:95cqi}}.\32 xl\:diff:has(.diff-item-1:focus-visible){outline-style:var(--tw-outline-style);outline-offset:1px;outline-width:2px;& .diff-resizer{min-width:5cqi;max-width:5cqi}}.\32 xl\:diff:hover .diff-item-2:after{height:2.4rem}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.\32 xl\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.\32 xl\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.\32 xl\:diff:focus .diff-resizer{min-width:5cqi;max-width:5cqi}.\32 xl\:diff:has(.diff-item-1:focus) .diff-resizer{min-width:95cqi;max-width:95cqi}}}}@layer daisyui.l1.l2.l3{.\32 xl\:diff-resizer{isolation:isolate;z-index:2;resize:horizontal;opacity:0;cursor:ew-resize;transform-origin:100% 100%;clip-path:inset(calc(100% - .75rem) 0 0 calc(100% - .75rem));grid-row-start:2;grid-column-start:1;width:50cqi;min-width:1rem;max-width:calc(100cqi - 1rem);height:.75rem;transition:min-width .3s ease-out,max-width .3s ease-out;position:relative;overflow:hidden;transform:scaleY(5)translate(.32rem,50%)}.\32 xl\:diff-item-2{grid-row:1/span 3;grid-column-start:1;position:relative}.\32 xl\:diff-item-2:after{pointer-events:none;z-index:2;background-color:color-mix(in oklab, var(--color-base-100) 98%, transparent);border:2px solid var(--color-base-100);content:"";outline:2px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);outline-offset:-3px;width:1.2rem;height:1.8rem;transition:height .3s linear(0, .931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);border-radius:3.40282e38px;position:absolute;top:50%;bottom:0;right:1px;translate:50% -50%;box-shadow:0 0 0 2px #0000002a}.\32 xl\:diff-item-2>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}@supports (-webkit-overflow-scrolling:touch) and (overflow:-webkit-paged-x){.\32 xl\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}@supports ((-moz-appearance:none)){@media (hover:none) and (pointer:coarse){.\32 xl\:diff-item-2:after{--tw-content:none;content:var(--tw-content)}}}.\32 xl\:diff-item-1{z-index:1;border-right:2px solid var(--color-base-100);grid-row:1/span 3;grid-column-start:1;position:relative;overflow:hidden;box-shadow:0 0 0 2px #0000002a}.\32 xl\:diff-item-1:focus-visible{--tw-outline-style:none;outline-style:none}.\32 xl\:diff-item-1>*{pointer-events:none;object-fit:cover;object-position:center;width:100cqi;max-width:none;height:100%;position:absolute;top:0;bottom:0;left:0}}}@layer daisyui.l1.l2.l3.l4{.card figure:first-child{border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.card figure:last-child{border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.card figure{justify-content:center;align-items:center;display:flex}.card-body p{flex-grow:1}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:card{border-radius:var(--radius-box);outline-offset:2px;outline:2px solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.sm\:card:focus-visible,.sm\:card[aria-checked=true],.sm\:card:has(>:checked,>:is([type=checkbox],[type=radio]):focus-visible){outline-color:currentColor}.sm\:card:has(>:checked:focus-visible),.sm\:card[aria-checked=true]:focus-visible,.sm\:card[aria-checked=true]:has(>:is([type=checkbox],[type=radio]):focus-visible){outline-width:4px}.sm\:card:has(>:is([type=checkbox],[type=radio])){cursor:pointer;user-select:none}.sm\:card>:is([type=checkbox],[type=radio]){appearance:none}.sm\:card-title{font-size:var(--cardtitle-fs,1.125rem);align-items:center;gap:.5rem;font-weight:600;display:flex}.sm\:card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.sm\:card-actions{flex-wrap:wrap;align-items:flex-start;gap:.5rem;display:flex}}@layer daisyui.l1.l2.l3.l4{.sm\:card figure:first-child{border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.sm\:card figure:last-child{border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.sm\:card figure{justify-content:center;align-items:center;display:flex}.sm\:card-body p{flex-grow:1}}@layer daisyui.l1.l2{.sm\:card-border{border:var(--border) solid var(--color-base-200)}.sm\:card-dash{border:var(--border) dashed var(--color-base-200)}.sm\:image-full{display:grid}.sm\:image-full>*{grid-row-start:1;grid-column-start:1}.sm\:image-full>.card-body{color:var(--color-neutral-content);position:relative}.sm\:image-full :where(figure){border-radius:inherit;overflow:hidden}.sm\:image-full>figure img{object-fit:cover;filter:brightness(28%);height:100%}.sm\:card-xs .card-body{--card-p:.5rem;--card-fs:.6875rem}.sm\:card-xs .card-title{--cardtitle-fs:.875rem}.sm\:card-sm .card-body{--card-p:1rem;--card-fs:.75rem}.sm\:card-sm .card-title{--cardtitle-fs:1rem}.sm\:card-md .card-body{--card-p:1.5rem;--card-fs:.875rem}.sm\:card-md .card-title{--cardtitle-fs:1.125rem}.sm\:card-lg .card-body{--card-p:2rem;--card-fs:1rem}.sm\:card-lg .card-title{--cardtitle-fs:1.25rem}.sm\:card-xl .card-body{--card-p:2.5rem;--card-fs:1.125rem}.sm\:card-xl .card-title{--cardtitle-fs:1.375rem}.sm\:card-side{flex-direction:row;align-items:stretch}.sm\:card-side :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:unset;border-end-end-radius:unset;border-end-start-radius:inherit;overflow:hidden}.sm\:card-side :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:inherit;border-end-end-radius:inherit;border-end-start-radius:unset;overflow:hidden}.sm\:card-side figure>*{max-width:unset}.sm\:card-side :where(figure>*){object-fit:cover;width:100%;height:100%}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:card{border-radius:var(--radius-box);outline-offset:2px;outline:2px solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.md\:card:focus-visible,.md\:card[aria-checked=true],.md\:card:has(>:checked,>:is([type=checkbox],[type=radio]):focus-visible){outline-color:currentColor}.md\:card:has(>:checked:focus-visible),.md\:card[aria-checked=true]:focus-visible,.md\:card[aria-checked=true]:has(>:is([type=checkbox],[type=radio]):focus-visible){outline-width:4px}.md\:card:has(>:is([type=checkbox],[type=radio])){cursor:pointer;user-select:none}.md\:card>:is([type=checkbox],[type=radio]){appearance:none}.md\:card-title{font-size:var(--cardtitle-fs,1.125rem);align-items:center;gap:.5rem;font-weight:600;display:flex}.md\:card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.md\:card-actions{flex-wrap:wrap;align-items:flex-start;gap:.5rem;display:flex}}@layer daisyui.l1.l2.l3.l4{.md\:card figure:first-child{border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.md\:card figure:last-child{border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.md\:card figure{justify-content:center;align-items:center;display:flex}.md\:card-body p{flex-grow:1}}@layer daisyui.l1.l2{.md\:card-border{border:var(--border) solid var(--color-base-200)}.md\:card-dash{border:var(--border) dashed var(--color-base-200)}.md\:image-full{display:grid}.md\:image-full>*{grid-row-start:1;grid-column-start:1}.md\:image-full>.card-body{color:var(--color-neutral-content);position:relative}.md\:image-full :where(figure){border-radius:inherit;overflow:hidden}.md\:image-full>figure img{object-fit:cover;filter:brightness(28%);height:100%}.md\:card-xs .card-body{--card-p:.5rem;--card-fs:.6875rem}.md\:card-xs .card-title{--cardtitle-fs:.875rem}.md\:card-sm .card-body{--card-p:1rem;--card-fs:.75rem}.md\:card-sm .card-title{--cardtitle-fs:1rem}.md\:card-md .card-body{--card-p:1.5rem;--card-fs:.875rem}.md\:card-md .card-title{--cardtitle-fs:1.125rem}.md\:card-lg .card-body{--card-p:2rem;--card-fs:1rem}.md\:card-lg .card-title{--cardtitle-fs:1.25rem}.md\:card-xl .card-body{--card-p:2.5rem;--card-fs:1.125rem}.md\:card-xl .card-title{--cardtitle-fs:1.375rem}.md\:card-side{flex-direction:row;align-items:stretch}.md\:card-side :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:unset;border-end-end-radius:unset;border-end-start-radius:inherit;overflow:hidden}.md\:card-side :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:inherit;border-end-end-radius:inherit;border-end-start-radius:unset;overflow:hidden}.md\:card-side figure>*{max-width:unset}.md\:card-side :where(figure>*){object-fit:cover;width:100%;height:100%}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:card{border-radius:var(--radius-box);outline-offset:2px;outline:2px solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.lg\:card:focus-visible,.lg\:card[aria-checked=true],.lg\:card:has(>:checked,>:is([type=checkbox],[type=radio]):focus-visible){outline-color:currentColor}.lg\:card:has(>:checked:focus-visible),.lg\:card[aria-checked=true]:focus-visible,.lg\:card[aria-checked=true]:has(>:is([type=checkbox],[type=radio]):focus-visible){outline-width:4px}.lg\:card:has(>:is([type=checkbox],[type=radio])){cursor:pointer;user-select:none}.lg\:card>:is([type=checkbox],[type=radio]){appearance:none}.lg\:card-title{font-size:var(--cardtitle-fs,1.125rem);align-items:center;gap:.5rem;font-weight:600;display:flex}.lg\:card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.lg\:card-actions{flex-wrap:wrap;align-items:flex-start;gap:.5rem;display:flex}}@layer daisyui.l1.l2.l3.l4{.lg\:card figure:first-child{border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.lg\:card figure:last-child{border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.lg\:card figure{justify-content:center;align-items:center;display:flex}.lg\:card-body p{flex-grow:1}}@layer daisyui.l1.l2{.lg\:card-border{border:var(--border) solid var(--color-base-200)}.lg\:card-dash{border:var(--border) dashed var(--color-base-200)}.lg\:image-full{display:grid}.lg\:image-full>*{grid-row-start:1;grid-column-start:1}.lg\:image-full>.card-body{color:var(--color-neutral-content);position:relative}.lg\:image-full :where(figure){border-radius:inherit;overflow:hidden}.lg\:image-full>figure img{object-fit:cover;filter:brightness(28%);height:100%}.lg\:card-xs .card-body{--card-p:.5rem;--card-fs:.6875rem}.lg\:card-xs .card-title{--cardtitle-fs:.875rem}.lg\:card-sm .card-body{--card-p:1rem;--card-fs:.75rem}.lg\:card-sm .card-title{--cardtitle-fs:1rem}.lg\:card-md .card-body{--card-p:1.5rem;--card-fs:.875rem}.lg\:card-md .card-title{--cardtitle-fs:1.125rem}.lg\:card-lg .card-body{--card-p:2rem;--card-fs:1rem}.lg\:card-lg .card-title{--cardtitle-fs:1.25rem}.lg\:card-xl .card-body{--card-p:2.5rem;--card-fs:1.125rem}.lg\:card-xl .card-title{--cardtitle-fs:1.375rem}.lg\:card-side{flex-direction:row;align-items:stretch}.lg\:card-side :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:unset;border-end-end-radius:unset;border-end-start-radius:inherit;overflow:hidden}.lg\:card-side :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:inherit;border-end-end-radius:inherit;border-end-start-radius:unset;overflow:hidden}.lg\:card-side figure>*{max-width:unset}.lg\:card-side :where(figure>*){object-fit:cover;width:100%;height:100%}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:card{border-radius:var(--radius-box);outline-offset:2px;outline:2px solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.xl\:card:focus-visible,.xl\:card[aria-checked=true],.xl\:card:has(>:checked,>:is([type=checkbox],[type=radio]):focus-visible){outline-color:currentColor}.xl\:card:has(>:checked:focus-visible),.xl\:card[aria-checked=true]:focus-visible,.xl\:card[aria-checked=true]:has(>:is([type=checkbox],[type=radio]):focus-visible){outline-width:4px}.xl\:card:has(>:is([type=checkbox],[type=radio])){cursor:pointer;user-select:none}.xl\:card>:is([type=checkbox],[type=radio]){appearance:none}.xl\:card-title{font-size:var(--cardtitle-fs,1.125rem);align-items:center;gap:.5rem;font-weight:600;display:flex}.xl\:card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.xl\:card-actions{flex-wrap:wrap;align-items:flex-start;gap:.5rem;display:flex}}@layer daisyui.l1.l2.l3.l4{.xl\:card figure:first-child{border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.xl\:card figure:last-child{border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.xl\:card figure{justify-content:center;align-items:center;display:flex}.xl\:card-body p{flex-grow:1}}@layer daisyui.l1.l2{.xl\:card-border{border:var(--border) solid var(--color-base-200)}.xl\:card-dash{border:var(--border) dashed var(--color-base-200)}.xl\:image-full{display:grid}.xl\:image-full>*{grid-row-start:1;grid-column-start:1}.xl\:image-full>.card-body{color:var(--color-neutral-content);position:relative}.xl\:image-full :where(figure){border-radius:inherit;overflow:hidden}.xl\:image-full>figure img{object-fit:cover;filter:brightness(28%);height:100%}.xl\:card-xs .card-body{--card-p:.5rem;--card-fs:.6875rem}.xl\:card-xs .card-title{--cardtitle-fs:.875rem}.xl\:card-sm .card-body{--card-p:1rem;--card-fs:.75rem}.xl\:card-sm .card-title{--cardtitle-fs:1rem}.xl\:card-md .card-body{--card-p:1.5rem;--card-fs:.875rem}.xl\:card-md .card-title{--cardtitle-fs:1.125rem}.xl\:card-lg .card-body{--card-p:2rem;--card-fs:1rem}.xl\:card-lg .card-title{--cardtitle-fs:1.25rem}.xl\:card-xl .card-body{--card-p:2.5rem;--card-fs:1.125rem}.xl\:card-xl .card-title{--cardtitle-fs:1.375rem}.xl\:card-side{flex-direction:row;align-items:stretch}.xl\:card-side :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:unset;border-end-end-radius:unset;border-end-start-radius:inherit;overflow:hidden}.xl\:card-side :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:inherit;border-end-end-radius:inherit;border-end-start-radius:unset;overflow:hidden}.xl\:card-side figure>*{max-width:unset}.xl\:card-side :where(figure>*){object-fit:cover;width:100%;height:100%}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:card{border-radius:var(--radius-box);outline-offset:2px;outline:2px solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.\32 xl\:card:focus-visible,.\32 xl\:card[aria-checked=true],.\32 xl\:card:has(>:checked,>:is([type=checkbox],[type=radio]):focus-visible){outline-color:currentColor}.\32 xl\:card:has(>:checked:focus-visible),.\32 xl\:card[aria-checked=true]:focus-visible,.\32 xl\:card[aria-checked=true]:has(>:is([type=checkbox],[type=radio]):focus-visible){outline-width:4px}.\32 xl\:card:has(>:is([type=checkbox],[type=radio])){cursor:pointer;user-select:none}.\32 xl\:card>:is([type=checkbox],[type=radio]){appearance:none}.\32 xl\:card-title{font-size:var(--cardtitle-fs,1.125rem);align-items:center;gap:.5rem;font-weight:600;display:flex}.\32 xl\:card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.\32 xl\:card-actions{flex-wrap:wrap;align-items:flex-start;gap:.5rem;display:flex}}@layer daisyui.l1.l2.l3.l4{.\32 xl\:card figure:first-child{border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.\32 xl\:card figure:last-child{border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.\32 xl\:card figure{justify-content:center;align-items:center;display:flex}.\32 xl\:card-body p{flex-grow:1}}@layer daisyui.l1.l2{.\32 xl\:card-border{border:var(--border) solid var(--color-base-200)}.\32 xl\:card-dash{border:var(--border) dashed var(--color-base-200)}.\32 xl\:image-full{display:grid}.\32 xl\:image-full>*{grid-row-start:1;grid-column-start:1}.\32 xl\:image-full>.card-body{color:var(--color-neutral-content);position:relative}.\32 xl\:image-full :where(figure){border-radius:inherit;overflow:hidden}.\32 xl\:image-full>figure img{object-fit:cover;filter:brightness(28%);height:100%}.\32 xl\:card-xs .card-body{--card-p:.5rem;--card-fs:.6875rem}.\32 xl\:card-xs .card-title{--cardtitle-fs:.875rem}.\32 xl\:card-sm .card-body{--card-p:1rem;--card-fs:.75rem}.\32 xl\:card-sm .card-title{--cardtitle-fs:1rem}.\32 xl\:card-md .card-body{--card-p:1.5rem;--card-fs:.875rem}.\32 xl\:card-md .card-title{--cardtitle-fs:1.125rem}.\32 xl\:card-lg .card-body{--card-p:2rem;--card-fs:1rem}.\32 xl\:card-lg .card-title{--cardtitle-fs:1.25rem}.\32 xl\:card-xl .card-body{--card-p:2.5rem;--card-fs:1.125rem}.\32 xl\:card-xl .card-title{--cardtitle-fs:1.375rem}.\32 xl\:card-side{flex-direction:row;align-items:stretch}.\32 xl\:card-side :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:unset;border-end-end-radius:unset;border-end-start-radius:inherit;overflow:hidden}.\32 xl\:card-side :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:inherit;border-end-end-radius:inherit;border-end-start-radius:unset;overflow:hidden}.\32 xl\:card-side figure>*{max-width:unset}.\32 xl\:card-side :where(figure>*){object-fit:cover;width:100%;height:100%}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:label{white-space:nowrap;color:color-mix(in oklab, currentcolor 60%, transparent);align-items:center;gap:.375rem;display:inline-flex}.sm\:label:has(input){cursor:pointer}.sm\:label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex;&:first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:-.75rem .75rem}&:last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:.75rem -.75rem}}.sm\:floating-label{display:flex;position:relative}.sm\:floating-label ::placeholder{transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out}.sm\:floating-label>span{z-index:1;background-color:var(--color-base-100);opacity:0;font-size:var(--font-size,.875rem);inset-inline-start:.75rem;top:calc(var(--size-field,.25rem) * var(--top-mul,5));pointer-events:none;border-radius:2px;padding-inline:.25rem;line-height:1;transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out;position:absolute;translate:0 -50%}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) ::placeholder{opacity:0;pointer-events:auto;top:0;translate:-12.5% calc(-50% - .125em);scale:.75}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) :dir(rtl)::placeholder{translate:12.5% calc(-50% - .125em)}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown)))>span{opacity:1;pointer-events:auto;z-index:2;top:0;translate:-12.5% calc(-50% - .125em);scale:.75;&:dir(rtl){translate:12.5% calc(-50% - .125em)}}.sm\:floating-label:has(:disabled,[disabled])>span{opacity:0}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:label{white-space:nowrap;color:color-mix(in oklab, currentcolor 60%, transparent);align-items:center;gap:.375rem;display:inline-flex}.md\:label:has(input){cursor:pointer}.md\:label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex;&:first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:-.75rem .75rem}&:last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:.75rem -.75rem}}.md\:floating-label{display:flex;position:relative}.md\:floating-label ::placeholder{transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out}.md\:floating-label>span{z-index:1;background-color:var(--color-base-100);opacity:0;font-size:var(--font-size,.875rem);inset-inline-start:.75rem;top:calc(var(--size-field,.25rem) * var(--top-mul,5));pointer-events:none;border-radius:2px;padding-inline:.25rem;line-height:1;transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out;position:absolute;translate:0 -50%}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) ::placeholder{opacity:0;pointer-events:auto;top:0;translate:-12.5% calc(-50% - .125em);scale:.75}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) :dir(rtl)::placeholder{translate:12.5% calc(-50% - .125em)}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown)))>span{opacity:1;pointer-events:auto;z-index:2;top:0;translate:-12.5% calc(-50% - .125em);scale:.75;&:dir(rtl){translate:12.5% calc(-50% - .125em)}}.md\:floating-label:has(:disabled,[disabled])>span{opacity:0}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:label{white-space:nowrap;color:color-mix(in oklab, currentcolor 60%, transparent);align-items:center;gap:.375rem;display:inline-flex}.lg\:label:has(input){cursor:pointer}.lg\:label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex;&:first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:-.75rem .75rem}&:last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:.75rem -.75rem}}.lg\:floating-label{display:flex;position:relative}.lg\:floating-label ::placeholder{transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out}.lg\:floating-label>span{z-index:1;background-color:var(--color-base-100);opacity:0;font-size:var(--font-size,.875rem);inset-inline-start:.75rem;top:calc(var(--size-field,.25rem) * var(--top-mul,5));pointer-events:none;border-radius:2px;padding-inline:.25rem;line-height:1;transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out;position:absolute;translate:0 -50%}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) ::placeholder{opacity:0;pointer-events:auto;top:0;translate:-12.5% calc(-50% - .125em);scale:.75}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) :dir(rtl)::placeholder{translate:12.5% calc(-50% - .125em)}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown)))>span{opacity:1;pointer-events:auto;z-index:2;top:0;translate:-12.5% calc(-50% - .125em);scale:.75;&:dir(rtl){translate:12.5% calc(-50% - .125em)}}.lg\:floating-label:has(:disabled,[disabled])>span{opacity:0}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:label{white-space:nowrap;color:color-mix(in oklab, currentcolor 60%, transparent);align-items:center;gap:.375rem;display:inline-flex}.xl\:label:has(input){cursor:pointer}.xl\:label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex;&:first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:-.75rem .75rem}&:last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:.75rem -.75rem}}.xl\:floating-label{display:flex;position:relative}.xl\:floating-label ::placeholder{transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out}.xl\:floating-label>span{z-index:1;background-color:var(--color-base-100);opacity:0;font-size:var(--font-size,.875rem);inset-inline-start:.75rem;top:calc(var(--size-field,.25rem) * var(--top-mul,5));pointer-events:none;border-radius:2px;padding-inline:.25rem;line-height:1;transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out;position:absolute;translate:0 -50%}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) ::placeholder{opacity:0;pointer-events:auto;top:0;translate:-12.5% calc(-50% - .125em);scale:.75}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) :dir(rtl)::placeholder{translate:12.5% calc(-50% - .125em)}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown)))>span{opacity:1;pointer-events:auto;z-index:2;top:0;translate:-12.5% calc(-50% - .125em);scale:.75;&:dir(rtl){translate:12.5% calc(-50% - .125em)}}.xl\:floating-label:has(:disabled,[disabled])>span{opacity:0}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:label{white-space:nowrap;color:color-mix(in oklab, currentcolor 60%, transparent);align-items:center;gap:.375rem;display:inline-flex}.\32 xl\:label:has(input){cursor:pointer}.\32 xl\:label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex;&:first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:-.75rem .75rem}&:last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000);margin-inline:.75rem -.75rem}}.\32 xl\:floating-label{display:flex;position:relative}.\32 xl\:floating-label ::placeholder{transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out}.\32 xl\:floating-label>span{z-index:1;background-color:var(--color-base-100);opacity:0;font-size:var(--font-size,.875rem);inset-inline-start:.75rem;top:calc(var(--size-field,.25rem) * var(--top-mul,5));pointer-events:none;border-radius:2px;padding-inline:.25rem;line-height:1;transition:top .1s ease-out,translate .1s ease-out,scale .1s ease-out,opacity .1s ease-out;position:absolute;translate:0 -50%}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) ::placeholder{opacity:0;pointer-events:auto;top:0;translate:-12.5% calc(-50% - .125em);scale:.75}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown))) :dir(rtl)::placeholder{translate:12.5% calc(-50% - .125em)}:is(.floating-label:focus-within,.floating-label:not(:has(input:placeholder-shown,textarea:placeholder-shown)))>span{opacity:1;pointer-events:auto;z-index:2;top:0;translate:-12.5% calc(-50% - .125em);scale:.75;&:dir(rtl){translate:12.5% calc(-50% - .125em)}}.\32 xl\:floating-label:has(:disabled,[disabled])>span{opacity:0}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.sm\:tab:is(.tabs>.tab){cursor:pointer;appearance:none;text-align:center;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative;&:hover{@media (hover:hover){color:var(--color-base-content)}}--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem;&:is(input[type=radio]){min-width:fit-content;&:after{--tw-content:attr(aria-label);content:var(--tw-content)}}&:is(label){position:relative;& input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}}&:checked,&:is(label:has(:checked)),&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){&+.tab-content{display:block}}&:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}&:not(input):empty{cursor:default;flex-grow:1}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}&[disabled]{pointer-events:none;opacity:.4}}.sm\:tab-content{--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box);--tabcontent-order:1;width:100%;height:calc(100% - var(--tab-height) + var(--border));margin:var(--tabcontent-margin);order:1;order:var(--tabcontent-order);border-color:#0000;border-width:var(--border);border-start-start-radius:var(--tabcontent-radius-ss);border-start-end-radius:var(--tabcontent-radius-se);border-end-end-radius:var(--tabcontent-radius-ee);border-end-start-radius:var(--tabcontent-radius-es);display:none}}@layer daisyui.l1.l2{.sm\:tab-disabled{pointer-events:none;opacity:.4}.sm\:tabs-border>.tab{--tab-border-color:#0000 #0000 var(--tab-border-color) #0000;border-radius:var(--radius-field);position:relative;&:before{content:"";background-color:var(--tab-border-color);width:calc(100% - var(--tab-p) * 2);border-radius:var(--radius-field);height:3px;bottom:0;left:var(--tab-p);transition:background-color .2s;position:absolute}&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){&:before{--tab-border-color:currentColor;border-top:3px solid}}}.sm\:tabs-lift{--tabs-height:auto;--tabs-direction:row}.sm\:tabs-lift>.tab{--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es);&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked,label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad));background-color:var(--tab-bg);&:before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start), var(--radius-end);background-size:var(--tab-radius-limit) var(--tab-radius-limit);inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}&:first-child:before{--radius-start:none}[dir=rtl] &:first-child:before{transform:rotateY(180deg)}&:last-child:before{--radius-end:none}[dir=rtl] &:last-child:before{transform:rotateY(180deg)}}}.sm\:tabs-lift:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.sm\:tabs-lift>.tab-content{--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.sm\:tabs-top{--tabs-height:auto;--tabs-direction:row}.sm\:tabs-top>.tab{--tab-order:0;--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad))}}.sm\:tabs-top:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.sm\:tabs-top>.tab-content{--tabcontent-order:1;--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.sm\:tabs-bottom{--tabs-height:auto;--tabs-direction:row}.sm\:tabs-bottom>.tab{--tab-order:1;--tab-border:var(--border) 0 0 0;--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:var(--tab-radius-limit);--tab-radius-ee:var(--tab-radius-limit);--tab-border-colors:var(--tab-border-color) #0000 #0000 #0000;--tab-paddings:0 var(--tab-p) var(--border) var(--tab-p);--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:0 var(--border) var(--border) var(--border);--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color);--tab-paddings:var(--border) calc(var(--tab-p) - var(--border)) 0 calc(var(--tab-p) - var(--border));--tab-inset:0 auto auto auto;--radius-start:radial-gradient(circle at bottom left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at bottom right, var(--tab-radius-grad))}}.sm\:tabs-bottom:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color)}.sm\:tabs-bottom>.tab-content{--tabcontent-order:0;--tabcontent-margin:0 0 calc(-1 * var(--border)) 0;--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:0;--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-bottom>:checked,.tabs-bottom>:is(label:has(:checked)),.tabs-bottom>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:not(:nth-child(2)){--tabcontent-radius-es:var(--radius-box)}.sm\:tabs-box{background-color:var(--color-base-200);--tabs-box-radius:calc(3 * var(--radius-field));border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)));box-shadow:0 -.5px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 .5px oklch(0% 0 0 / calc(var(--depth) * .05)) inset;padding:.25rem}.sm\:tabs-box>.tab{border-radius:var(--radius-field);border-style:none;&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:2px}&:focus-visible{z-index:1}}.sm\:tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.sm\:tabs-box>:is(input:checked),.sm\:tabs-box>:is(label:has(:checked)){background-color:var(--tab-bg,var(--color-base-100));box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);@media (forced-colors:active){border:1px solid}}.sm\:tabs-box>.tab-content{height:calc(100% - var(--tab-height) + var(--border) - .5rem);border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)) - var(--border));margin-top:.25rem}.sm\:tabs-xs{--tab-height:calc(var(--size-field,.25rem) * 6)}.sm\:tabs-xs>.tab{--tab-p:.375rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.75rem}.sm\:tabs-sm{--tab-height:calc(var(--size-field,.25rem) * 8)}.sm\:tabs-sm>.tab{--tab-p:.5rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.875rem}.sm\:tabs-md{--tab-height:calc(var(--size-field,.25rem) * 10)}.sm\:tabs-md>.tab{--tab-p:.75rem;--tab-radius-min:calc(.75rem - var(--border));font-size:.875rem}.sm\:tabs-lg{--tab-height:calc(var(--size-field,.25rem) * 12)}.sm\:tabs-lg>.tab{--tab-p:1rem;--tab-radius-min:calc(1.5rem - var(--border));font-size:1.125rem}.sm\:tabs-xl{--tab-height:calc(var(--size-field,.25rem) * 14)}.sm\:tabs-xl>.tab{--tab-p:1.25rem;--tab-radius-min:calc(2rem - var(--border));font-size:1.125rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.md\:tab:is(.tabs>.tab){cursor:pointer;appearance:none;text-align:center;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative;&:hover{@media (hover:hover){color:var(--color-base-content)}}--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem;&:is(input[type=radio]){min-width:fit-content;&:after{--tw-content:attr(aria-label);content:var(--tw-content)}}&:is(label){position:relative;& input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}}&:checked,&:is(label:has(:checked)),&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){&+.tab-content{display:block}}&:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}&:not(input):empty{cursor:default;flex-grow:1}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}&[disabled]{pointer-events:none;opacity:.4}}.md\:tab-content{--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box);--tabcontent-order:1;width:100%;height:calc(100% - var(--tab-height) + var(--border));margin:var(--tabcontent-margin);order:1;order:var(--tabcontent-order);border-color:#0000;border-width:var(--border);border-start-start-radius:var(--tabcontent-radius-ss);border-start-end-radius:var(--tabcontent-radius-se);border-end-end-radius:var(--tabcontent-radius-ee);border-end-start-radius:var(--tabcontent-radius-es);display:none}}@layer daisyui.l1.l2{.md\:tab-disabled{pointer-events:none;opacity:.4}.md\:tabs-border>.tab{--tab-border-color:#0000 #0000 var(--tab-border-color) #0000;border-radius:var(--radius-field);position:relative;&:before{content:"";background-color:var(--tab-border-color);width:calc(100% - var(--tab-p) * 2);border-radius:var(--radius-field);height:3px;bottom:0;left:var(--tab-p);transition:background-color .2s;position:absolute}&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){&:before{--tab-border-color:currentColor;border-top:3px solid}}}.md\:tabs-lift{--tabs-height:auto;--tabs-direction:row}.md\:tabs-lift>.tab{--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es);&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked,label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad));background-color:var(--tab-bg);&:before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start), var(--radius-end);background-size:var(--tab-radius-limit) var(--tab-radius-limit);inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}&:first-child:before{--radius-start:none}[dir=rtl] &:first-child:before{transform:rotateY(180deg)}&:last-child:before{--radius-end:none}[dir=rtl] &:last-child:before{transform:rotateY(180deg)}}}.md\:tabs-lift:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.md\:tabs-lift>.tab-content{--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.md\:tabs-top{--tabs-height:auto;--tabs-direction:row}.md\:tabs-top>.tab{--tab-order:0;--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad))}}.md\:tabs-top:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.md\:tabs-top>.tab-content{--tabcontent-order:1;--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.md\:tabs-bottom{--tabs-height:auto;--tabs-direction:row}.md\:tabs-bottom>.tab{--tab-order:1;--tab-border:var(--border) 0 0 0;--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:var(--tab-radius-limit);--tab-radius-ee:var(--tab-radius-limit);--tab-border-colors:var(--tab-border-color) #0000 #0000 #0000;--tab-paddings:0 var(--tab-p) var(--border) var(--tab-p);--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:0 var(--border) var(--border) var(--border);--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color);--tab-paddings:var(--border) calc(var(--tab-p) - var(--border)) 0 calc(var(--tab-p) - var(--border));--tab-inset:0 auto auto auto;--radius-start:radial-gradient(circle at bottom left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at bottom right, var(--tab-radius-grad))}}.md\:tabs-bottom:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color)}.md\:tabs-bottom>.tab-content{--tabcontent-order:0;--tabcontent-margin:0 0 calc(-1 * var(--border)) 0;--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:0;--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-bottom>:checked,.tabs-bottom>:is(label:has(:checked)),.tabs-bottom>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:not(:nth-child(2)){--tabcontent-radius-es:var(--radius-box)}.md\:tabs-box{background-color:var(--color-base-200);--tabs-box-radius:calc(3 * var(--radius-field));border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)));box-shadow:0 -.5px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 .5px oklch(0% 0 0 / calc(var(--depth) * .05)) inset;padding:.25rem}.md\:tabs-box>.tab{border-radius:var(--radius-field);border-style:none;&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:2px}&:focus-visible{z-index:1}}.md\:tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.md\:tabs-box>:is(input:checked),.md\:tabs-box>:is(label:has(:checked)){background-color:var(--tab-bg,var(--color-base-100));box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);@media (forced-colors:active){border:1px solid}}.md\:tabs-box>.tab-content{height:calc(100% - var(--tab-height) + var(--border) - .5rem);border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)) - var(--border));margin-top:.25rem}.md\:tabs-xs{--tab-height:calc(var(--size-field,.25rem) * 6)}.md\:tabs-xs>.tab{--tab-p:.375rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.75rem}.md\:tabs-sm{--tab-height:calc(var(--size-field,.25rem) * 8)}.md\:tabs-sm>.tab{--tab-p:.5rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.875rem}.md\:tabs-md{--tab-height:calc(var(--size-field,.25rem) * 10)}.md\:tabs-md>.tab{--tab-p:.75rem;--tab-radius-min:calc(.75rem - var(--border));font-size:.875rem}.md\:tabs-lg{--tab-height:calc(var(--size-field,.25rem) * 12)}.md\:tabs-lg>.tab{--tab-p:1rem;--tab-radius-min:calc(1.5rem - var(--border));font-size:1.125rem}.md\:tabs-xl{--tab-height:calc(var(--size-field,.25rem) * 14)}.md\:tabs-xl>.tab{--tab-p:1.25rem;--tab-radius-min:calc(2rem - var(--border));font-size:1.125rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.lg\:tab:is(.tabs>.tab){cursor:pointer;appearance:none;text-align:center;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative;&:hover{@media (hover:hover){color:var(--color-base-content)}}--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem;&:is(input[type=radio]){min-width:fit-content;&:after{--tw-content:attr(aria-label);content:var(--tw-content)}}&:is(label){position:relative;& input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}}&:checked,&:is(label:has(:checked)),&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){&+.tab-content{display:block}}&:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}&:not(input):empty{cursor:default;flex-grow:1}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}&[disabled]{pointer-events:none;opacity:.4}}.lg\:tab-content{--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box);--tabcontent-order:1;width:100%;height:calc(100% - var(--tab-height) + var(--border));margin:var(--tabcontent-margin);order:1;order:var(--tabcontent-order);border-color:#0000;border-width:var(--border);border-start-start-radius:var(--tabcontent-radius-ss);border-start-end-radius:var(--tabcontent-radius-se);border-end-end-radius:var(--tabcontent-radius-ee);border-end-start-radius:var(--tabcontent-radius-es);display:none}}@layer daisyui.l1.l2{.lg\:tab-disabled{pointer-events:none;opacity:.4}.lg\:tabs-border>.tab{--tab-border-color:#0000 #0000 var(--tab-border-color) #0000;border-radius:var(--radius-field);position:relative;&:before{content:"";background-color:var(--tab-border-color);width:calc(100% - var(--tab-p) * 2);border-radius:var(--radius-field);height:3px;bottom:0;left:var(--tab-p);transition:background-color .2s;position:absolute}&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){&:before{--tab-border-color:currentColor;border-top:3px solid}}}.lg\:tabs-lift{--tabs-height:auto;--tabs-direction:row}.lg\:tabs-lift>.tab{--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es);&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked,label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad));background-color:var(--tab-bg);&:before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start), var(--radius-end);background-size:var(--tab-radius-limit) var(--tab-radius-limit);inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}&:first-child:before{--radius-start:none}[dir=rtl] &:first-child:before{transform:rotateY(180deg)}&:last-child:before{--radius-end:none}[dir=rtl] &:last-child:before{transform:rotateY(180deg)}}}.lg\:tabs-lift:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.lg\:tabs-lift>.tab-content{--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.lg\:tabs-top{--tabs-height:auto;--tabs-direction:row}.lg\:tabs-top>.tab{--tab-order:0;--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad))}}.lg\:tabs-top:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.lg\:tabs-top>.tab-content{--tabcontent-order:1;--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.lg\:tabs-bottom{--tabs-height:auto;--tabs-direction:row}.lg\:tabs-bottom>.tab{--tab-order:1;--tab-border:var(--border) 0 0 0;--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:var(--tab-radius-limit);--tab-radius-ee:var(--tab-radius-limit);--tab-border-colors:var(--tab-border-color) #0000 #0000 #0000;--tab-paddings:0 var(--tab-p) var(--border) var(--tab-p);--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:0 var(--border) var(--border) var(--border);--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color);--tab-paddings:var(--border) calc(var(--tab-p) - var(--border)) 0 calc(var(--tab-p) - var(--border));--tab-inset:0 auto auto auto;--radius-start:radial-gradient(circle at bottom left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at bottom right, var(--tab-radius-grad))}}.lg\:tabs-bottom:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color)}.lg\:tabs-bottom>.tab-content{--tabcontent-order:0;--tabcontent-margin:0 0 calc(-1 * var(--border)) 0;--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:0;--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-bottom>:checked,.tabs-bottom>:is(label:has(:checked)),.tabs-bottom>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:not(:nth-child(2)){--tabcontent-radius-es:var(--radius-box)}.lg\:tabs-box{background-color:var(--color-base-200);--tabs-box-radius:calc(3 * var(--radius-field));border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)));box-shadow:0 -.5px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 .5px oklch(0% 0 0 / calc(var(--depth) * .05)) inset;padding:.25rem}.lg\:tabs-box>.tab{border-radius:var(--radius-field);border-style:none;&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:2px}&:focus-visible{z-index:1}}.lg\:tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.lg\:tabs-box>:is(input:checked),.lg\:tabs-box>:is(label:has(:checked)){background-color:var(--tab-bg,var(--color-base-100));box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);@media (forced-colors:active){border:1px solid}}.lg\:tabs-box>.tab-content{height:calc(100% - var(--tab-height) + var(--border) - .5rem);border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)) - var(--border));margin-top:.25rem}.lg\:tabs-xs{--tab-height:calc(var(--size-field,.25rem) * 6)}.lg\:tabs-xs>.tab{--tab-p:.375rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.75rem}.lg\:tabs-sm{--tab-height:calc(var(--size-field,.25rem) * 8)}.lg\:tabs-sm>.tab{--tab-p:.5rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.875rem}.lg\:tabs-md{--tab-height:calc(var(--size-field,.25rem) * 10)}.lg\:tabs-md>.tab{--tab-p:.75rem;--tab-radius-min:calc(.75rem - var(--border));font-size:.875rem}.lg\:tabs-lg{--tab-height:calc(var(--size-field,.25rem) * 12)}.lg\:tabs-lg>.tab{--tab-p:1rem;--tab-radius-min:calc(1.5rem - var(--border));font-size:1.125rem}.lg\:tabs-xl{--tab-height:calc(var(--size-field,.25rem) * 14)}.lg\:tabs-xl>.tab{--tab-p:1.25rem;--tab-radius-min:calc(2rem - var(--border));font-size:1.125rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.xl\:tab:is(.tabs>.tab){cursor:pointer;appearance:none;text-align:center;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative;&:hover{@media (hover:hover){color:var(--color-base-content)}}--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem;&:is(input[type=radio]){min-width:fit-content;&:after{--tw-content:attr(aria-label);content:var(--tw-content)}}&:is(label){position:relative;& input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}}&:checked,&:is(label:has(:checked)),&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){&+.tab-content{display:block}}&:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}&:not(input):empty{cursor:default;flex-grow:1}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}&[disabled]{pointer-events:none;opacity:.4}}.xl\:tab-content{--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box);--tabcontent-order:1;width:100%;height:calc(100% - var(--tab-height) + var(--border));margin:var(--tabcontent-margin);order:1;order:var(--tabcontent-order);border-color:#0000;border-width:var(--border);border-start-start-radius:var(--tabcontent-radius-ss);border-start-end-radius:var(--tabcontent-radius-se);border-end-end-radius:var(--tabcontent-radius-ee);border-end-start-radius:var(--tabcontent-radius-es);display:none}}@layer daisyui.l1.l2{.xl\:tab-disabled{pointer-events:none;opacity:.4}.xl\:tabs-border>.tab{--tab-border-color:#0000 #0000 var(--tab-border-color) #0000;border-radius:var(--radius-field);position:relative;&:before{content:"";background-color:var(--tab-border-color);width:calc(100% - var(--tab-p) * 2);border-radius:var(--radius-field);height:3px;bottom:0;left:var(--tab-p);transition:background-color .2s;position:absolute}&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){&:before{--tab-border-color:currentColor;border-top:3px solid}}}.xl\:tabs-lift{--tabs-height:auto;--tabs-direction:row}.xl\:tabs-lift>.tab{--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es);&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked,label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad));background-color:var(--tab-bg);&:before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start), var(--radius-end);background-size:var(--tab-radius-limit) var(--tab-radius-limit);inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}&:first-child:before{--radius-start:none}[dir=rtl] &:first-child:before{transform:rotateY(180deg)}&:last-child:before{--radius-end:none}[dir=rtl] &:last-child:before{transform:rotateY(180deg)}}}.xl\:tabs-lift:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.xl\:tabs-lift>.tab-content{--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.xl\:tabs-top{--tabs-height:auto;--tabs-direction:row}.xl\:tabs-top>.tab{--tab-order:0;--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad))}}.xl\:tabs-top:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.xl\:tabs-top>.tab-content{--tabcontent-order:1;--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.xl\:tabs-bottom{--tabs-height:auto;--tabs-direction:row}.xl\:tabs-bottom>.tab{--tab-order:1;--tab-border:var(--border) 0 0 0;--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:var(--tab-radius-limit);--tab-radius-ee:var(--tab-radius-limit);--tab-border-colors:var(--tab-border-color) #0000 #0000 #0000;--tab-paddings:0 var(--tab-p) var(--border) var(--tab-p);--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:0 var(--border) var(--border) var(--border);--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color);--tab-paddings:var(--border) calc(var(--tab-p) - var(--border)) 0 calc(var(--tab-p) - var(--border));--tab-inset:0 auto auto auto;--radius-start:radial-gradient(circle at bottom left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at bottom right, var(--tab-radius-grad))}}.xl\:tabs-bottom:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color)}.xl\:tabs-bottom>.tab-content{--tabcontent-order:0;--tabcontent-margin:0 0 calc(-1 * var(--border)) 0;--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:0;--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-bottom>:checked,.tabs-bottom>:is(label:has(:checked)),.tabs-bottom>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:not(:nth-child(2)){--tabcontent-radius-es:var(--radius-box)}.xl\:tabs-box{background-color:var(--color-base-200);--tabs-box-radius:calc(3 * var(--radius-field));border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)));box-shadow:0 -.5px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 .5px oklch(0% 0 0 / calc(var(--depth) * .05)) inset;padding:.25rem}.xl\:tabs-box>.tab{border-radius:var(--radius-field);border-style:none;&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:2px}&:focus-visible{z-index:1}}.xl\:tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.xl\:tabs-box>:is(input:checked),.xl\:tabs-box>:is(label:has(:checked)){background-color:var(--tab-bg,var(--color-base-100));box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);@media (forced-colors:active){border:1px solid}}.xl\:tabs-box>.tab-content{height:calc(100% - var(--tab-height) + var(--border) - .5rem);border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)) - var(--border));margin-top:.25rem}.xl\:tabs-xs{--tab-height:calc(var(--size-field,.25rem) * 6)}.xl\:tabs-xs>.tab{--tab-p:.375rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.75rem}.xl\:tabs-sm{--tab-height:calc(var(--size-field,.25rem) * 8)}.xl\:tabs-sm>.tab{--tab-p:.5rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.875rem}.xl\:tabs-md{--tab-height:calc(var(--size-field,.25rem) * 10)}.xl\:tabs-md>.tab{--tab-p:.75rem;--tab-radius-min:calc(.75rem - var(--border));font-size:.875rem}.xl\:tabs-lg{--tab-height:calc(var(--size-field,.25rem) * 12)}.xl\:tabs-lg>.tab{--tab-p:1rem;--tab-radius-min:calc(1.5rem - var(--border));font-size:1.125rem}.xl\:tabs-xl{--tab-height:calc(var(--size-field,.25rem) * 14)}.xl\:tabs-xl>.tab{--tab-p:1.25rem;--tab-radius-min:calc(2rem - var(--border));font-size:1.125rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:tabs{--tabs-height:auto;--tabs-direction:row;--tab-height:calc(var(--size-field,.25rem) * 10);height:var(--tabs-height);flex-wrap:wrap;flex-direction:var(--tabs-direction);display:flex}.\32 xl\:tab:is(.tabs>.tab){cursor:pointer;appearance:none;text-align:center;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative;&:hover{@media (hover:hover){color:var(--color-base-content)}}--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem;&:is(input[type=radio]){min-width:fit-content;&:after{--tw-content:attr(aria-label);content:var(--tw-content)}}&:is(label){position:relative;& input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}}&:checked,&:is(label:has(:checked)),&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){&+.tab-content{display:block}}&:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}&:not(input):empty{cursor:default;flex-grow:1}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}&[disabled]{pointer-events:none;opacity:.4}}.\32 xl\:tab-content{--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box);--tabcontent-order:1;width:100%;height:calc(100% - var(--tab-height) + var(--border));margin:var(--tabcontent-margin);order:1;order:var(--tabcontent-order);border-color:#0000;border-width:var(--border);border-start-start-radius:var(--tabcontent-radius-ss);border-start-end-radius:var(--tabcontent-radius-se);border-end-end-radius:var(--tabcontent-radius-ee);border-end-start-radius:var(--tabcontent-radius-es);display:none}}@layer daisyui.l1.l2{.\32 xl\:tab-disabled{pointer-events:none;opacity:.4}.\32 xl\:tabs-border>.tab{--tab-border-color:#0000 #0000 var(--tab-border-color) #0000;border-radius:var(--radius-field);position:relative;&:before{content:"";background-color:var(--tab-border-color);width:calc(100% - var(--tab-p) * 2);border-radius:var(--radius-field);height:3px;bottom:0;left:var(--tab-p);transition:background-color .2s;position:absolute}&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){&:before{--tab-border-color:currentColor;border-top:3px solid}}}.\32 xl\:tabs-lift{--tabs-height:auto;--tabs-direction:row}.\32 xl\:tabs-lift>.tab{--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;border-width:var(--tab-border);padding:var(--tab-paddings);border-color:var(--tab-border-colors);border-start-start-radius:var(--tab-radius-ss);border-start-end-radius:var(--tab-radius-se);border-end-end-radius:var(--tab-radius-ee);border-end-start-radius:var(--tab-radius-es);&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked,label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad));background-color:var(--tab-bg);&:before{z-index:1;content:"";width:var(--tab-corner-width);height:var(--tab-corner-height);background-position:var(--tab-corner-position);background-image:var(--radius-start), var(--radius-end);background-size:var(--tab-radius-limit) var(--tab-radius-limit);inset:var(--tab-inset);background-repeat:no-repeat;display:block;position:absolute}&:first-child:before{--radius-start:none}[dir=rtl] &:first-child:before{transform:rotateY(180deg)}&:last-child:before{--radius-end:none}[dir=rtl] &:last-child:before{transform:rotateY(180deg)}}}.\32 xl\:tabs-lift:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.\32 xl\:tabs-lift>.tab-content{--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-lift :checked,.tabs-lift label:has(:checked),.tabs-lift :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.\32 xl\:tabs-top{--tabs-height:auto;--tabs-direction:row}.\32 xl\:tabs-top>.tab{--tab-order:0;--tab-border:0 0 var(--border) 0;--tab-radius-ss:var(--tab-radius-limit);--tab-radius-se:var(--tab-radius-limit);--tab-radius-es:0;--tab-radius-ee:0;--tab-paddings:var(--border) var(--tab-p) 0 var(--tab-p);--tab-border-colors:#0000 #0000 var(--tab-border-color) #0000;--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:var(--border) var(--border) 0 var(--border);--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color);--tab-paddings:0 calc(var(--tab-p) - var(--border)) var(--border) calc(var(--tab-p) - var(--border));--tab-inset:auto auto 0 auto;--radius-start:radial-gradient(circle at top left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at top right, var(--tab-radius-grad))}}.\32 xl\:tabs-top:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:var(--tab-border-color) var(--tab-border-color) #0000 var(--tab-border-color)}.\32 xl\:tabs-top>.tab-content{--tabcontent-order:1;--tabcontent-margin:calc(-1 * var(--border)) 0 0 0;--tabcontent-radius-ss:0;--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:var(--radius-box);--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:first-child,:is(.tabs-top :checked,.tabs-top label:has(:checked),.tabs-top :is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:nth-child(n+3){--tabcontent-radius-ss:var(--radius-box)}.\32 xl\:tabs-bottom{--tabs-height:auto;--tabs-direction:row}.\32 xl\:tabs-bottom>.tab{--tab-order:1;--tab-border:var(--border) 0 0 0;--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:var(--tab-radius-limit);--tab-radius-ee:var(--tab-radius-limit);--tab-border-colors:var(--tab-border-color) #0000 #0000 #0000;--tab-paddings:0 var(--tab-p) var(--border) var(--tab-p);--tab-corner-width:calc(100% + var(--tab-radius-limit) * 2);--tab-corner-height:var(--tab-radius-limit);--tab-corner-position:top left, top right;&:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),&:is(input:checked),&:is(label:has(:checked)){--tab-border:0 var(--border) var(--border) var(--border);--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color);--tab-paddings:var(--border) calc(var(--tab-p) - var(--border)) 0 calc(var(--tab-p) - var(--border));--tab-inset:0 auto auto auto;--radius-start:radial-gradient(circle at bottom left, var(--tab-radius-grad));--radius-end:radial-gradient(circle at bottom right, var(--tab-radius-grad))}}.\32 xl\:tabs-bottom:has(>.tab-content)>.tab:first-child:not(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){--tab-border-colors:#0000 var(--tab-border-color) var(--tab-border-color) var(--tab-border-color)}.\32 xl\:tabs-bottom>.tab-content{--tabcontent-order:0;--tabcontent-margin:0 0 calc(-1 * var(--border)) 0;--tabcontent-radius-ss:var(--radius-box);--tabcontent-radius-se:var(--radius-box);--tabcontent-radius-es:0;--tabcontent-radius-ee:var(--radius-box)}:is(.tabs-bottom>:checked,.tabs-bottom>:is(label:has(:checked)),.tabs-bottom>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content:not(:nth-child(2)){--tabcontent-radius-es:var(--radius-box)}.\32 xl\:tabs-box{background-color:var(--color-base-200);--tabs-box-radius:calc(3 * var(--radius-field));border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)));box-shadow:0 -.5px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 .5px oklch(0% 0 0 / calc(var(--depth) * .05)) inset;padding:.25rem}.\32 xl\:tabs-box>.tab{border-radius:var(--radius-field);border-style:none;&:focus-visible,&:is(label:has(:checked:focus-visible)){outline-offset:2px}&:focus-visible{z-index:1}}.\32 xl\:tabs-box>:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]):not(.tab-disabled,[disabled]),.\32 xl\:tabs-box>:is(input:checked),.\32 xl\:tabs-box>:is(label:has(:checked)){background-color:var(--tab-bg,var(--color-base-100));box-shadow:0 1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);@media (forced-colors:active){border:1px solid}}.\32 xl\:tabs-box>.tab-content{height:calc(100% - var(--tab-height) + var(--border) - .5rem);border-radius:calc(min(var(--tab-height) / 2, var(--radius-field)) + min(.25rem, var(--tabs-box-radius)) - var(--border));margin-top:.25rem}.\32 xl\:tabs-xs{--tab-height:calc(var(--size-field,.25rem) * 6)}.\32 xl\:tabs-xs>.tab{--tab-p:.375rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.75rem}.\32 xl\:tabs-sm{--tab-height:calc(var(--size-field,.25rem) * 8)}.\32 xl\:tabs-sm>.tab{--tab-p:.5rem;--tab-radius-min:calc(.5rem - var(--border));font-size:.875rem}.\32 xl\:tabs-md{--tab-height:calc(var(--size-field,.25rem) * 10)}.\32 xl\:tabs-md>.tab{--tab-p:.75rem;--tab-radius-min:calc(.75rem - var(--border));font-size:.875rem}.\32 xl\:tabs-lg{--tab-height:calc(var(--size-field,.25rem) * 12)}.\32 xl\:tabs-lg>.tab{--tab-p:1rem;--tab-radius-min:calc(1.5rem - var(--border));font-size:1.125rem}.\32 xl\:tabs-xl{--tab-height:calc(var(--size-field,.25rem) * 14)}.\32 xl\:tabs-xl>.tab{--tab-p:1.25rem;--tab-radius-min:calc(2rem - var(--border));font-size:1.125rem}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:select{appearance:none;background-color:var(--color-base-100);vertical-align:middle;--size:calc(var(--size-field,.25rem) * var(--sl-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;display:inline-flex;position:relative;overflow:hidden}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}.sm\:select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.sm\:select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:not(:last-child){background-image:none;margin-inline-end:-1.375rem}}.sm\:select:focus,.sm\:select:focus-within,.sm\:select:open{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}.sm\:select:open{background-image:linear-gradient(135deg,#0000 50%,currentColor 50%),linear-gradient(45deg,currentColor 50%,#0000 50%)}@supports (appearance:base-select){:is(.select,.select select),:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) selectedcontent{text-overflow:ellipsis;white-space:nowrap;width:100%;overflow:hidden}:is(.select,.select select) optgroup{padding-top:.5em;& option{&:first-child{margin-top:.5em}}}:is(.select,.select select) option{border-radius:var(--radius-field);padding-block:.375rem;padding-inline:calc(.25rem * var(--option-px,3));white-space:normal;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);&:not(:disabled){&:hover,&:focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}}}[dir=rtl] .select::picker(select),[dir=rtl] .select select::picker(select){translate:.5rem}}@layer daisyui.l1.l2{.sm\:select:has(>select[disabled]),.sm\:select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(select),& :is(select){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.sm\:select:has(>select[disabled])>select[disabled]{cursor:not-allowed}.sm\:select-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.sm\:select-ghost:focus,.sm\:select-ghost:focus-within,.sm\:select-ghost:open{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.sm\:select-ghost::picker(select){background-color:var(--color-base-100);color:var(--color-base-content)}.select-neutral,.sm\:select-neutral:focus,.sm\:select-neutral:focus-within,.sm\:select-neutral:open{--input-color:var(--color-neutral)}.select-primary,.sm\:select-primary:focus,.sm\:select-primary:focus-within,.sm\:select-primary:open{--input-color:var(--color-primary)}.select-secondary,.sm\:select-secondary:focus,.sm\:select-secondary:focus-within,.sm\:select-secondary:open{--input-color:var(--color-secondary)}.select-accent,.sm\:select-accent:focus,.sm\:select-accent:focus-within,.sm\:select-accent:open{--input-color:var(--color-accent)}.select-info,.sm\:select-info:focus,.sm\:select-info:focus-within,.sm\:select-info:open{--input-color:var(--color-info)}.select-success,.sm\:select-success:focus,.sm\:select-success:focus-within,.sm\:select-success:open{--input-color:var(--color-success)}.select-warning,.sm\:select-warning:focus,.sm\:select-warning:focus-within,.sm\:select-warning:open{--input-color:var(--color-warning)}.select-error,.sm\:select-error:focus,.sm\:select-error:focus-within,.sm\:select-error:open{--input-color:var(--color-error)}.sm\:select-xs{--sl-size-mul:6;--font-size-min:.6875rem;--option-px:2}.sm\:floating-label:has(.select-xs){--top-mul:3;--font-size:.6875rem}.sm\:select-sm{--sl-size-mul:8;--font-size-min:.75rem;--option-px:2.5}.sm\:floating-label:has(.select-sm){--top-mul:4;--font-size:.75rem}.sm\:select-md{--sl-size-mul:10;--font-size-min:.875rem;--option-px:3}.sm\:floating-label:has(.select-md){--top-mul:5;--font-size:.875rem}.sm\:select-lg{--sl-size-mul:12;--font-size-min:1.125rem;--option-px:4}.sm\:floating-label:has(.select-lg){--top-mul:6;--font-size:1.125rem}.sm\:select-xl{--sl-size-mul:14;--font-size-min:1.375rem;--option-px:5}.sm\:floating-label:has(.select-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:select{appearance:none;background-color:var(--color-base-100);vertical-align:middle;--size:calc(var(--size-field,.25rem) * var(--sl-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;display:inline-flex;position:relative;overflow:hidden}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}.md\:select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.md\:select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:not(:last-child){background-image:none;margin-inline-end:-1.375rem}}.md\:select:focus,.md\:select:focus-within,.md\:select:open{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}.md\:select:open{background-image:linear-gradient(135deg,#0000 50%,currentColor 50%),linear-gradient(45deg,currentColor 50%,#0000 50%)}@supports (appearance:base-select){:is(.select,.select select),:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) selectedcontent{text-overflow:ellipsis;white-space:nowrap;width:100%;overflow:hidden}:is(.select,.select select) optgroup{padding-top:.5em;& option{&:first-child{margin-top:.5em}}}:is(.select,.select select) option{border-radius:var(--radius-field);padding-block:.375rem;padding-inline:calc(.25rem * var(--option-px,3));white-space:normal;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);&:not(:disabled){&:hover,&:focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}}}[dir=rtl] .select::picker(select),[dir=rtl] .select select::picker(select){translate:.5rem}}@layer daisyui.l1.l2{.md\:select:has(>select[disabled]),.md\:select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(select),& :is(select){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.md\:select:has(>select[disabled])>select[disabled]{cursor:not-allowed}.md\:select-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.md\:select-ghost:focus,.md\:select-ghost:focus-within,.md\:select-ghost:open{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.md\:select-ghost::picker(select){background-color:var(--color-base-100);color:var(--color-base-content)}.select-neutral,.md\:select-neutral:focus,.md\:select-neutral:focus-within,.md\:select-neutral:open{--input-color:var(--color-neutral)}.select-primary,.md\:select-primary:focus,.md\:select-primary:focus-within,.md\:select-primary:open{--input-color:var(--color-primary)}.select-secondary,.md\:select-secondary:focus,.md\:select-secondary:focus-within,.md\:select-secondary:open{--input-color:var(--color-secondary)}.select-accent,.md\:select-accent:focus,.md\:select-accent:focus-within,.md\:select-accent:open{--input-color:var(--color-accent)}.select-info,.md\:select-info:focus,.md\:select-info:focus-within,.md\:select-info:open{--input-color:var(--color-info)}.select-success,.md\:select-success:focus,.md\:select-success:focus-within,.md\:select-success:open{--input-color:var(--color-success)}.select-warning,.md\:select-warning:focus,.md\:select-warning:focus-within,.md\:select-warning:open{--input-color:var(--color-warning)}.select-error,.md\:select-error:focus,.md\:select-error:focus-within,.md\:select-error:open{--input-color:var(--color-error)}.md\:select-xs{--sl-size-mul:6;--font-size-min:.6875rem;--option-px:2}.md\:floating-label:has(.select-xs){--top-mul:3;--font-size:.6875rem}.md\:select-sm{--sl-size-mul:8;--font-size-min:.75rem;--option-px:2.5}.md\:floating-label:has(.select-sm){--top-mul:4;--font-size:.75rem}.md\:select-md{--sl-size-mul:10;--font-size-min:.875rem;--option-px:3}.md\:floating-label:has(.select-md){--top-mul:5;--font-size:.875rem}.md\:select-lg{--sl-size-mul:12;--font-size-min:1.125rem;--option-px:4}.md\:floating-label:has(.select-lg){--top-mul:6;--font-size:1.125rem}.md\:select-xl{--sl-size-mul:14;--font-size-min:1.375rem;--option-px:5}.md\:floating-label:has(.select-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:select{appearance:none;background-color:var(--color-base-100);vertical-align:middle;--size:calc(var(--size-field,.25rem) * var(--sl-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;display:inline-flex;position:relative;overflow:hidden}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}.lg\:select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.lg\:select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:not(:last-child){background-image:none;margin-inline-end:-1.375rem}}.lg\:select:focus,.lg\:select:focus-within,.lg\:select:open{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}.lg\:select:open{background-image:linear-gradient(135deg,#0000 50%,currentColor 50%),linear-gradient(45deg,currentColor 50%,#0000 50%)}@supports (appearance:base-select){:is(.select,.select select),:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) selectedcontent{text-overflow:ellipsis;white-space:nowrap;width:100%;overflow:hidden}:is(.select,.select select) optgroup{padding-top:.5em;& option{&:first-child{margin-top:.5em}}}:is(.select,.select select) option{border-radius:var(--radius-field);padding-block:.375rem;padding-inline:calc(.25rem * var(--option-px,3));white-space:normal;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);&:not(:disabled){&:hover,&:focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}}}[dir=rtl] .select::picker(select),[dir=rtl] .select select::picker(select){translate:.5rem}}@layer daisyui.l1.l2{.lg\:select:has(>select[disabled]),.lg\:select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(select),& :is(select){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.lg\:select:has(>select[disabled])>select[disabled]{cursor:not-allowed}.lg\:select-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.lg\:select-ghost:focus,.lg\:select-ghost:focus-within,.lg\:select-ghost:open{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.lg\:select-ghost::picker(select){background-color:var(--color-base-100);color:var(--color-base-content)}.select-neutral,.lg\:select-neutral:focus,.lg\:select-neutral:focus-within,.lg\:select-neutral:open{--input-color:var(--color-neutral)}.select-primary,.lg\:select-primary:focus,.lg\:select-primary:focus-within,.lg\:select-primary:open{--input-color:var(--color-primary)}.select-secondary,.lg\:select-secondary:focus,.lg\:select-secondary:focus-within,.lg\:select-secondary:open{--input-color:var(--color-secondary)}.select-accent,.lg\:select-accent:focus,.lg\:select-accent:focus-within,.lg\:select-accent:open{--input-color:var(--color-accent)}.select-info,.lg\:select-info:focus,.lg\:select-info:focus-within,.lg\:select-info:open{--input-color:var(--color-info)}.select-success,.lg\:select-success:focus,.lg\:select-success:focus-within,.lg\:select-success:open{--input-color:var(--color-success)}.select-warning,.lg\:select-warning:focus,.lg\:select-warning:focus-within,.lg\:select-warning:open{--input-color:var(--color-warning)}.select-error,.lg\:select-error:focus,.lg\:select-error:focus-within,.lg\:select-error:open{--input-color:var(--color-error)}.lg\:select-xs{--sl-size-mul:6;--font-size-min:.6875rem;--option-px:2}.lg\:floating-label:has(.select-xs){--top-mul:3;--font-size:.6875rem}.lg\:select-sm{--sl-size-mul:8;--font-size-min:.75rem;--option-px:2.5}.lg\:floating-label:has(.select-sm){--top-mul:4;--font-size:.75rem}.lg\:select-md{--sl-size-mul:10;--font-size-min:.875rem;--option-px:3}.lg\:floating-label:has(.select-md){--top-mul:5;--font-size:.875rem}.lg\:select-lg{--sl-size-mul:12;--font-size-min:1.125rem;--option-px:4}.lg\:floating-label:has(.select-lg){--top-mul:6;--font-size:1.125rem}.lg\:select-xl{--sl-size-mul:14;--font-size-min:1.375rem;--option-px:5}.lg\:floating-label:has(.select-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:select{appearance:none;background-color:var(--color-base-100);vertical-align:middle;--size:calc(var(--size-field,.25rem) * var(--sl-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;display:inline-flex;position:relative;overflow:hidden}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}.xl\:select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.xl\:select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:not(:last-child){background-image:none;margin-inline-end:-1.375rem}}.xl\:select:focus,.xl\:select:focus-within,.xl\:select:open{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}.xl\:select:open{background-image:linear-gradient(135deg,#0000 50%,currentColor 50%),linear-gradient(45deg,currentColor 50%,#0000 50%)}@supports (appearance:base-select){:is(.select,.select select),:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) selectedcontent{text-overflow:ellipsis;white-space:nowrap;width:100%;overflow:hidden}:is(.select,.select select) optgroup{padding-top:.5em;& option{&:first-child{margin-top:.5em}}}:is(.select,.select select) option{border-radius:var(--radius-field);padding-block:.375rem;padding-inline:calc(.25rem * var(--option-px,3));white-space:normal;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);&:not(:disabled){&:hover,&:focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}}}[dir=rtl] .select::picker(select),[dir=rtl] .select select::picker(select){translate:.5rem}}@layer daisyui.l1.l2{.xl\:select:has(>select[disabled]),.xl\:select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(select),& :is(select){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.xl\:select:has(>select[disabled])>select[disabled]{cursor:not-allowed}.xl\:select-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.xl\:select-ghost:focus,.xl\:select-ghost:focus-within,.xl\:select-ghost:open{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.xl\:select-ghost::picker(select){background-color:var(--color-base-100);color:var(--color-base-content)}.select-neutral,.xl\:select-neutral:focus,.xl\:select-neutral:focus-within,.xl\:select-neutral:open{--input-color:var(--color-neutral)}.select-primary,.xl\:select-primary:focus,.xl\:select-primary:focus-within,.xl\:select-primary:open{--input-color:var(--color-primary)}.select-secondary,.xl\:select-secondary:focus,.xl\:select-secondary:focus-within,.xl\:select-secondary:open{--input-color:var(--color-secondary)}.select-accent,.xl\:select-accent:focus,.xl\:select-accent:focus-within,.xl\:select-accent:open{--input-color:var(--color-accent)}.select-info,.xl\:select-info:focus,.xl\:select-info:focus-within,.xl\:select-info:open{--input-color:var(--color-info)}.select-success,.xl\:select-success:focus,.xl\:select-success:focus-within,.xl\:select-success:open{--input-color:var(--color-success)}.select-warning,.xl\:select-warning:focus,.xl\:select-warning:focus-within,.xl\:select-warning:open{--input-color:var(--color-warning)}.select-error,.xl\:select-error:focus,.xl\:select-error:focus-within,.xl\:select-error:open{--input-color:var(--color-error)}.xl\:select-xs{--sl-size-mul:6;--font-size-min:.6875rem;--option-px:2}.xl\:floating-label:has(.select-xs){--top-mul:3;--font-size:.6875rem}.xl\:select-sm{--sl-size-mul:8;--font-size-min:.75rem;--option-px:2.5}.xl\:floating-label:has(.select-sm){--top-mul:4;--font-size:.75rem}.xl\:select-md{--sl-size-mul:10;--font-size-min:.875rem;--option-px:3}.xl\:floating-label:has(.select-md){--top-mul:5;--font-size:.875rem}.xl\:select-lg{--sl-size-mul:12;--font-size-min:1.125rem;--option-px:4}.xl\:floating-label:has(.select-lg){--top-mul:6;--font-size:1.125rem}.xl\:select-xl{--sl-size-mul:14;--font-size-min:1.375rem;--option-px:5}.xl\:floating-label:has(.select-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:select{appearance:none;background-color:var(--color-base-100);vertical-align:middle;--size:calc(var(--size-field,.25rem) * var(--sl-size-mul,10));--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;display:inline-flex;position:relative;overflow:hidden}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}.\32 xl\:select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.\32 xl\:select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:not(:last-child){background-image:none;margin-inline-end:-1.375rem}}.\32 xl\:select:focus,.\32 xl\:select:focus-within,.\32 xl\:select:open{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px}.\32 xl\:select:open{background-image:linear-gradient(135deg,#0000 50%,currentColor 50%),linear-gradient(45deg,currentColor 50%,#0000 50%)}@supports (appearance:base-select){:is(.select,.select select),:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) selectedcontent{text-overflow:ellipsis;white-space:nowrap;width:100%;overflow:hidden}:is(.select,.select select) optgroup{padding-top:.5em;& option{&:first-child{margin-top:.5em}}}:is(.select,.select select) option{border-radius:var(--radius-field);padding-block:.375rem;padding-inline:calc(.25rem * var(--option-px,3));white-space:normal;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);&:not(:disabled){&:hover,&:focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}}}[dir=rtl] .select::picker(select),[dir=rtl] .select select::picker(select){translate:.5rem}}@layer daisyui.l1.l2{.\32 xl\:select:has(>select[disabled]),.\32 xl\:select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);&:is(select),& :is(select){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.\32 xl\:select:has(>select[disabled])>select[disabled]{cursor:not-allowed}.\32 xl\:select-ghost{box-shadow:none;background-color:#0000;border-color:#0000;transition:background-color .2s}.\32 xl\:select-ghost:focus,.\32 xl\:select-ghost:focus-within,.\32 xl\:select-ghost:open{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.\32 xl\:select-ghost::picker(select){background-color:var(--color-base-100);color:var(--color-base-content)}.select-neutral,.\32 xl\:select-neutral:focus,.\32 xl\:select-neutral:focus-within,.\32 xl\:select-neutral:open{--input-color:var(--color-neutral)}.select-primary,.\32 xl\:select-primary:focus,.\32 xl\:select-primary:focus-within,.\32 xl\:select-primary:open{--input-color:var(--color-primary)}.select-secondary,.\32 xl\:select-secondary:focus,.\32 xl\:select-secondary:focus-within,.\32 xl\:select-secondary:open{--input-color:var(--color-secondary)}.select-accent,.\32 xl\:select-accent:focus,.\32 xl\:select-accent:focus-within,.\32 xl\:select-accent:open{--input-color:var(--color-accent)}.select-info,.\32 xl\:select-info:focus,.\32 xl\:select-info:focus-within,.\32 xl\:select-info:open{--input-color:var(--color-info)}.select-success,.\32 xl\:select-success:focus,.\32 xl\:select-success:focus-within,.\32 xl\:select-success:open{--input-color:var(--color-success)}.select-warning,.\32 xl\:select-warning:focus,.\32 xl\:select-warning:focus-within,.\32 xl\:select-warning:open{--input-color:var(--color-warning)}.select-error,.\32 xl\:select-error:focus,.\32 xl\:select-error:focus-within,.\32 xl\:select-error:open{--input-color:var(--color-error)}.\32 xl\:select-xs{--sl-size-mul:6;--font-size-min:.6875rem;--option-px:2}.\32 xl\:floating-label:has(.select-xs){--top-mul:3;--font-size:.6875rem}.\32 xl\:select-sm{--sl-size-mul:8;--font-size-min:.75rem;--option-px:2.5}.\32 xl\:floating-label:has(.select-sm){--top-mul:4;--font-size:.75rem}.\32 xl\:select-md{--sl-size-mul:10;--font-size-min:.875rem;--option-px:3}.\32 xl\:floating-label:has(.select-md){--top-mul:5;--font-size:.875rem}.\32 xl\:select-lg{--sl-size-mul:12;--font-size-min:1.125rem;--option-px:4}.\32 xl\:floating-label:has(.select-lg){--top-mul:6;--font-size:1.125rem}.\32 xl\:select-xl{--sl-size-mul:14;--font-size-min:1.375rem;--option-px:5}.\32 xl\:floating-label:has(.select-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));flex-shrink:0;justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.sm\:badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}.sm\:badge-dash{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-style:dashed;border-color:currentColor}.sm\:badge-soft{color:var(--badge-color,var(--color-base-content));background-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 8%, var(--color-base-100));border-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 10%, var(--color-base-100));background-image:none}.sm\:badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.sm\:badge-secondary{--badge-color:var(--color-secondary);--badge-fg:var(--color-secondary-content)}.sm\:badge-accent{--badge-color:var(--color-accent);--badge-fg:var(--color-accent-content)}.sm\:badge-neutral{--badge-color:var(--color-neutral);--badge-fg:var(--color-neutral-content)}.sm\:badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.sm\:badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.sm\:badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.sm\:badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.sm\:badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.sm\:badge-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.sm\:badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.sm\:badge-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.sm\:badge-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.sm\:badge-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));flex-shrink:0;justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.md\:badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}.md\:badge-dash{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-style:dashed;border-color:currentColor}.md\:badge-soft{color:var(--badge-color,var(--color-base-content));background-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 8%, var(--color-base-100));border-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 10%, var(--color-base-100));background-image:none}.md\:badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.md\:badge-secondary{--badge-color:var(--color-secondary);--badge-fg:var(--color-secondary-content)}.md\:badge-accent{--badge-color:var(--color-accent);--badge-fg:var(--color-accent-content)}.md\:badge-neutral{--badge-color:var(--color-neutral);--badge-fg:var(--color-neutral-content)}.md\:badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.md\:badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.md\:badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.md\:badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.md\:badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.md\:badge-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.md\:badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.md\:badge-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.md\:badge-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.md\:badge-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));flex-shrink:0;justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.lg\:badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}.lg\:badge-dash{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-style:dashed;border-color:currentColor}.lg\:badge-soft{color:var(--badge-color,var(--color-base-content));background-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 8%, var(--color-base-100));border-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 10%, var(--color-base-100));background-image:none}.lg\:badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.lg\:badge-secondary{--badge-color:var(--color-secondary);--badge-fg:var(--color-secondary-content)}.lg\:badge-accent{--badge-color:var(--color-accent);--badge-fg:var(--color-accent-content)}.lg\:badge-neutral{--badge-color:var(--color-neutral);--badge-fg:var(--color-neutral-content)}.lg\:badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.lg\:badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.lg\:badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.lg\:badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.lg\:badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.lg\:badge-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.lg\:badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.lg\:badge-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.lg\:badge-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.lg\:badge-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));flex-shrink:0;justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.xl\:badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}.xl\:badge-dash{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-style:dashed;border-color:currentColor}.xl\:badge-soft{color:var(--badge-color,var(--color-base-content));background-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 8%, var(--color-base-100));border-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 10%, var(--color-base-100));background-image:none}.xl\:badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.xl\:badge-secondary{--badge-color:var(--color-secondary);--badge-fg:var(--color-secondary-content)}.xl\:badge-accent{--badge-color:var(--color-accent);--badge-fg:var(--color-accent-content)}.xl\:badge-neutral{--badge-color:var(--color-neutral);--badge-fg:var(--color-neutral-content)}.xl\:badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.xl\:badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.xl\:badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.xl\:badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.xl\:badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.xl\:badge-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.xl\:badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.xl\:badge-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.xl\:badge-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.xl\:badge-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));flex-shrink:0;justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.\32 xl\:badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}.\32 xl\:badge-dash{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-style:dashed;border-color:currentColor}.\32 xl\:badge-soft{color:var(--badge-color,var(--color-base-content));background-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 8%, var(--color-base-100));border-color:color-mix(in oklab, var(--badge-color,var(--color-base-content)) 10%, var(--color-base-100));background-image:none}.\32 xl\:badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.\32 xl\:badge-secondary{--badge-color:var(--color-secondary);--badge-fg:var(--color-secondary-content)}.\32 xl\:badge-accent{--badge-color:var(--color-accent);--badge-fg:var(--color-accent-content)}.\32 xl\:badge-neutral{--badge-color:var(--color-neutral);--badge-fg:var(--color-neutral-content)}.\32 xl\:badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.\32 xl\:badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.\32 xl\:badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.\32 xl\:badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.\32 xl\:badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.\32 xl\:badge-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.\32 xl\:badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.\32 xl\:badge-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.\32 xl\:badge-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.\32 xl\:badge-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}.kbd{box-shadow:none;@layer daisyui.l1.l2.l3{border-radius:var(--radius-field);background-color:var(--color-base-200);vertical-align:middle;color:var(--color-base-content);border:var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);border-bottom:calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);min-width:var(--size);flex-shrink:0;justify-content:center;align-items:center;padding-inline:.5em;font-size:.875rem;display:inline-flex}}@media (width>=640px){.sm\:kbd{box-shadow:none;@layer daisyui.l1.l2.l3{border-radius:var(--radius-field);background-color:var(--color-base-200);vertical-align:middle;color:var(--color-base-content);border:var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);border-bottom:calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);min-width:var(--size);flex-shrink:0;justify-content:center;align-items:center;padding-inline:.5em;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.sm\:kbd-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.sm\:kbd-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.sm\:kbd-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.sm\:kbd-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.sm\:kbd-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}@media (width>=768px){.md\:kbd{box-shadow:none;@layer daisyui.l1.l2.l3{border-radius:var(--radius-field);background-color:var(--color-base-200);vertical-align:middle;color:var(--color-base-content);border:var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);border-bottom:calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);min-width:var(--size);flex-shrink:0;justify-content:center;align-items:center;padding-inline:.5em;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.md\:kbd-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.md\:kbd-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.md\:kbd-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.md\:kbd-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.md\:kbd-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}@media (width>=1024px){.lg\:kbd{box-shadow:none;@layer daisyui.l1.l2.l3{border-radius:var(--radius-field);background-color:var(--color-base-200);vertical-align:middle;color:var(--color-base-content);border:var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);border-bottom:calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);min-width:var(--size);flex-shrink:0;justify-content:center;align-items:center;padding-inline:.5em;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.lg\:kbd-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.lg\:kbd-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.lg\:kbd-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.lg\:kbd-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.lg\:kbd-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}@media (width>=1280px){.xl\:kbd{box-shadow:none;@layer daisyui.l1.l2.l3{border-radius:var(--radius-field);background-color:var(--color-base-200);vertical-align:middle;color:var(--color-base-content);border:var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);border-bottom:calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);min-width:var(--size);flex-shrink:0;justify-content:center;align-items:center;padding-inline:.5em;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.xl\:kbd-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.xl\:kbd-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.xl\:kbd-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.xl\:kbd-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.xl\:kbd-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}@media (width>=1536px){.\32 xl\:kbd{box-shadow:none;@layer daisyui.l1.l2.l3{border-radius:var(--radius-field);background-color:var(--color-base-200);vertical-align:middle;color:var(--color-base-content);border:var(--border) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);border-bottom:calc(var(--border) + 1px) solid color-mix(in srgb, var(--color-base-content) 20%, #0000);--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);min-width:var(--size);flex-shrink:0;justify-content:center;align-items:center;padding-inline:.5em;font-size:.875rem;display:inline-flex}}@layer daisyui.l1.l2{.\32 xl\:kbd-xs{--size:calc(var(--size-selector,.25rem) * 4);font-size:.625rem}.\32 xl\:kbd-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.\32 xl\:kbd-md{--size:calc(var(--size-selector,.25rem) * 6);font-size:.875rem}.\32 xl\:kbd-lg{--size:calc(var(--size-selector,.25rem) * 7);font-size:1rem}.\32 xl\:kbd-xl{--size:calc(var(--size-selector,.25rem) * 8);font-size:1.125rem}}}.otp-joined{--otp-gap:0rem;clip-path:inset(-3.5px 0 -3.5px -3.5px);&>span{&:first-of-type{border-start-end-radius:0;border-end-end-radius:0}&:not(&:last-of-type){border-inline-end-style:var(--tw-border-style);border-inline-end-width:0}&:not(&:first-of-type,&:last-of-type){border-radius:0}&:last-of-type{border-start-start-radius:0;border-end-start-radius:0}}&:after{outline-offset:calc((-1 * var(--border)) - 3px);@supports (font:-apple-system-body){--otp-w:calc(var(--otp-ch) * 2 - 1px)}}&:has(input:valid:focus){&>span{outline-offset:calc((-1 * var(--border)) - 3px)}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:otp{font-family:var(--font-mono,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);clip-path:inset(-3.5px 3.5px -3.5px -3.5px);border-radius:var(--radius-field);gap:var(--otp-gap);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);--otp-ch:1ch;direction:ltr;font-size:1.75rem;display:inline-flex;position:relative}@supports (font:-apple-system-body){.sm\:otp{--otp-ch:.618164em}}.sm\:otp{--otp-gap:calc(var(--otp-ch) * .5);--otp-w:calc(var(--otp-ch) * 2);--otp-size:calc(var(--size-field,.25rem) * 10);--stride:calc(var(--otp-w) + var(--otp-gap))}.sm\:otp>input{pointer-events:none;z-index:1;appearance:none;border-style:var(--tw-border-style);outline-style:var(--tw-outline-style);field-sizing:content;text-indent:1px;letter-spacing:calc(var(--stride) - var(--otp-ch));font-variant-numeric:tabular-nums;background-color:#0000;border-width:0;outline-width:0;margin:0;padding:0;padding-inline-start:calc(var(--otp-ch) * .5 - 1px);line-height:1;inset-inline-start:0;&::selection{color:color-mix(in oklab, var(--color-base-content) 20%, #0000);background-color:#0000}&:valid{caret-color:#0000}}.sm\:otp:has(>span:first-child){width:calc(var(--stride) * 1)}.sm\:otp:has(>span:nth-child(2)){width:calc(var(--stride) * 2)}.sm\:otp:has(>span:nth-child(3)){width:calc(var(--stride) * 3)}.sm\:otp:has(>span:nth-child(4)){width:calc(var(--stride) * 4)}.sm\:otp:has(>span:nth-child(5)){width:calc(var(--stride) * 5)}.sm\:otp:has(>span:nth-child(6)){width:calc(var(--stride) * 6)}.sm\:otp:has(>span:nth-child(7)){width:calc(var(--stride) * 7)}.sm\:otp:has(>span:nth-child(8)){width:calc(var(--stride) * 8)}.sm\:otp>span{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inline-size:var(--otp-w);block-size:var(--otp-size);background-color:var(--color-base-100);border:var(--border) solid var(--input-color);border-radius:inherit;outline-offset:1px;box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;outline:2px solid #0000;transition:border-color .2s;display:flex;position:absolute;&:first-child{left:0;@supports (font:-apple-system-body){left:1px}}&:nth-child(2){left:calc(var(--stride) * 1);transition-delay:20ms}&:nth-child(3){left:calc(var(--stride) * 2);transition-delay:40ms}&:nth-child(4){left:calc(var(--stride) * 3);transition-delay:60ms}&:nth-child(5){left:calc(var(--stride) * 4);transition-delay:80ms}&:nth-child(6){left:calc(var(--stride) * 5);transition-delay:.1s}&:nth-child(7){left:calc(var(--stride) * 6);transition-delay:.12s}&:nth-child(8){left:calc(var(--stride) * 7);transition-delay:.14s}}.sm\:otp:has(input:valid:focus)>span{outline:2px solid var(--input-color);outline-offset:1px}.sm\:otp:has(input:valid:focus):after{opacity:0}.sm\:otp:after{content:"";width:var(--otp-w);height:var(--otp-size);border-radius:var(--radius-field);outline-offset:1px;z-index:10;outline:2px solid #0000;flex-shrink:0;margin-inline-start:calc(-1 * (var(--otp-gap) + var(--otp-ch) * .5))}.sm\:otp:focus-within{--input-color:var(--color-base-content);&>span{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}&:after{outline-color:var(--input-color)}}.sm\:otp:has(>input:disabled){cursor:not-allowed;&>input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}&>span{border-color:var(--color-base-200);background-color:var(--color-base-200);box-shadow:none}}}.sm\:otp-joined{--otp-gap:0rem;clip-path:inset(-3.5px 0 -3.5px -3.5px);&>span{&:first-of-type{border-start-end-radius:0;border-end-end-radius:0}&:not(&:last-of-type){border-inline-end-style:var(--tw-border-style);border-inline-end-width:0}&:not(&:first-of-type,&:last-of-type){border-radius:0}&:last-of-type{border-start-start-radius:0;border-end-start-radius:0}}&:after{outline-offset:calc((-1 * var(--border)) - 3px);@supports (font:-apple-system-body){--otp-w:calc(var(--otp-ch) * 2 - 1px)}}&:has(input:valid:focus){&>span{outline-offset:calc((-1 * var(--border)) - 3px)}}}@layer daisyui.l1.l2{.sm\:otp-xs{--otp-size:calc(var(--size-field,.25rem) * 6);font-size:1.25rem}.sm\:otp-sm{--otp-size:calc(var(--size-field,.25rem) * 8);font-size:1.5rem}.sm\:otp-md{--otp-size:calc(var(--size-field,.25rem) * 10);font-size:1.75rem}.sm\:otp-lg{--otp-size:calc(var(--size-field,.25rem) * 12);font-size:2rem}.sm\:otp-xl{--otp-size:calc(var(--size-field,.25rem) * 14);font-size:2.5rem}.otp-neutral,.sm\:otp-neutral:focus,.sm\:otp-neutral:focus-within{--input-color:var(--color-neutral)}.otp-primary,.sm\:otp-primary:focus,.sm\:otp-primary:focus-within{--input-color:var(--color-primary)}.otp-secondary,.sm\:otp-secondary:focus,.sm\:otp-secondary:focus-within{--input-color:var(--color-secondary)}.otp-accent,.sm\:otp-accent:focus,.sm\:otp-accent:focus-within{--input-color:var(--color-accent)}.otp-info,.sm\:otp-info:focus,.sm\:otp-info:focus-within{--input-color:var(--color-info)}.otp-success,.sm\:otp-success:focus,.sm\:otp-success:focus-within{--input-color:var(--color-success)}.otp-warning,.sm\:otp-warning:focus,.sm\:otp-warning:focus-within{--input-color:var(--color-warning)}.otp-error,.sm\:otp-error:focus,.sm\:otp-error:focus-within{--input-color:var(--color-error)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:otp{font-family:var(--font-mono,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);clip-path:inset(-3.5px 3.5px -3.5px -3.5px);border-radius:var(--radius-field);gap:var(--otp-gap);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);--otp-ch:1ch;direction:ltr;font-size:1.75rem;display:inline-flex;position:relative}@supports (font:-apple-system-body){.md\:otp{--otp-ch:.618164em}}.md\:otp{--otp-gap:calc(var(--otp-ch) * .5);--otp-w:calc(var(--otp-ch) * 2);--otp-size:calc(var(--size-field,.25rem) * 10);--stride:calc(var(--otp-w) + var(--otp-gap))}.md\:otp>input{pointer-events:none;z-index:1;appearance:none;border-style:var(--tw-border-style);outline-style:var(--tw-outline-style);field-sizing:content;text-indent:1px;letter-spacing:calc(var(--stride) - var(--otp-ch));font-variant-numeric:tabular-nums;background-color:#0000;border-width:0;outline-width:0;margin:0;padding:0;padding-inline-start:calc(var(--otp-ch) * .5 - 1px);line-height:1;inset-inline-start:0;&::selection{color:color-mix(in oklab, var(--color-base-content) 20%, #0000);background-color:#0000}&:valid{caret-color:#0000}}.md\:otp:has(>span:first-child){width:calc(var(--stride) * 1)}.md\:otp:has(>span:nth-child(2)){width:calc(var(--stride) * 2)}.md\:otp:has(>span:nth-child(3)){width:calc(var(--stride) * 3)}.md\:otp:has(>span:nth-child(4)){width:calc(var(--stride) * 4)}.md\:otp:has(>span:nth-child(5)){width:calc(var(--stride) * 5)}.md\:otp:has(>span:nth-child(6)){width:calc(var(--stride) * 6)}.md\:otp:has(>span:nth-child(7)){width:calc(var(--stride) * 7)}.md\:otp:has(>span:nth-child(8)){width:calc(var(--stride) * 8)}.md\:otp>span{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inline-size:var(--otp-w);block-size:var(--otp-size);background-color:var(--color-base-100);border:var(--border) solid var(--input-color);border-radius:inherit;outline-offset:1px;box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;outline:2px solid #0000;transition:border-color .2s;display:flex;position:absolute;&:first-child{left:0;@supports (font:-apple-system-body){left:1px}}&:nth-child(2){left:calc(var(--stride) * 1);transition-delay:20ms}&:nth-child(3){left:calc(var(--stride) * 2);transition-delay:40ms}&:nth-child(4){left:calc(var(--stride) * 3);transition-delay:60ms}&:nth-child(5){left:calc(var(--stride) * 4);transition-delay:80ms}&:nth-child(6){left:calc(var(--stride) * 5);transition-delay:.1s}&:nth-child(7){left:calc(var(--stride) * 6);transition-delay:.12s}&:nth-child(8){left:calc(var(--stride) * 7);transition-delay:.14s}}.md\:otp:has(input:valid:focus)>span{outline:2px solid var(--input-color);outline-offset:1px}.md\:otp:has(input:valid:focus):after{opacity:0}.md\:otp:after{content:"";width:var(--otp-w);height:var(--otp-size);border-radius:var(--radius-field);outline-offset:1px;z-index:10;outline:2px solid #0000;flex-shrink:0;margin-inline-start:calc(-1 * (var(--otp-gap) + var(--otp-ch) * .5))}.md\:otp:focus-within{--input-color:var(--color-base-content);&>span{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}&:after{outline-color:var(--input-color)}}.md\:otp:has(>input:disabled){cursor:not-allowed;&>input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}&>span{border-color:var(--color-base-200);background-color:var(--color-base-200);box-shadow:none}}}.md\:otp-joined{--otp-gap:0rem;clip-path:inset(-3.5px 0 -3.5px -3.5px);&>span{&:first-of-type{border-start-end-radius:0;border-end-end-radius:0}&:not(&:last-of-type){border-inline-end-style:var(--tw-border-style);border-inline-end-width:0}&:not(&:first-of-type,&:last-of-type){border-radius:0}&:last-of-type{border-start-start-radius:0;border-end-start-radius:0}}&:after{outline-offset:calc((-1 * var(--border)) - 3px);@supports (font:-apple-system-body){--otp-w:calc(var(--otp-ch) * 2 - 1px)}}&:has(input:valid:focus){&>span{outline-offset:calc((-1 * var(--border)) - 3px)}}}@layer daisyui.l1.l2{.md\:otp-xs{--otp-size:calc(var(--size-field,.25rem) * 6);font-size:1.25rem}.md\:otp-sm{--otp-size:calc(var(--size-field,.25rem) * 8);font-size:1.5rem}.md\:otp-md{--otp-size:calc(var(--size-field,.25rem) * 10);font-size:1.75rem}.md\:otp-lg{--otp-size:calc(var(--size-field,.25rem) * 12);font-size:2rem}.md\:otp-xl{--otp-size:calc(var(--size-field,.25rem) * 14);font-size:2.5rem}.otp-neutral,.md\:otp-neutral:focus,.md\:otp-neutral:focus-within{--input-color:var(--color-neutral)}.otp-primary,.md\:otp-primary:focus,.md\:otp-primary:focus-within{--input-color:var(--color-primary)}.otp-secondary,.md\:otp-secondary:focus,.md\:otp-secondary:focus-within{--input-color:var(--color-secondary)}.otp-accent,.md\:otp-accent:focus,.md\:otp-accent:focus-within{--input-color:var(--color-accent)}.otp-info,.md\:otp-info:focus,.md\:otp-info:focus-within{--input-color:var(--color-info)}.otp-success,.md\:otp-success:focus,.md\:otp-success:focus-within{--input-color:var(--color-success)}.otp-warning,.md\:otp-warning:focus,.md\:otp-warning:focus-within{--input-color:var(--color-warning)}.otp-error,.md\:otp-error:focus,.md\:otp-error:focus-within{--input-color:var(--color-error)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:otp{font-family:var(--font-mono,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);clip-path:inset(-3.5px 3.5px -3.5px -3.5px);border-radius:var(--radius-field);gap:var(--otp-gap);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);--otp-ch:1ch;direction:ltr;font-size:1.75rem;display:inline-flex;position:relative}@supports (font:-apple-system-body){.lg\:otp{--otp-ch:.618164em}}.lg\:otp{--otp-gap:calc(var(--otp-ch) * .5);--otp-w:calc(var(--otp-ch) * 2);--otp-size:calc(var(--size-field,.25rem) * 10);--stride:calc(var(--otp-w) + var(--otp-gap))}.lg\:otp>input{pointer-events:none;z-index:1;appearance:none;border-style:var(--tw-border-style);outline-style:var(--tw-outline-style);field-sizing:content;text-indent:1px;letter-spacing:calc(var(--stride) - var(--otp-ch));font-variant-numeric:tabular-nums;background-color:#0000;border-width:0;outline-width:0;margin:0;padding:0;padding-inline-start:calc(var(--otp-ch) * .5 - 1px);line-height:1;inset-inline-start:0;&::selection{color:color-mix(in oklab, var(--color-base-content) 20%, #0000);background-color:#0000}&:valid{caret-color:#0000}}.lg\:otp:has(>span:first-child){width:calc(var(--stride) * 1)}.lg\:otp:has(>span:nth-child(2)){width:calc(var(--stride) * 2)}.lg\:otp:has(>span:nth-child(3)){width:calc(var(--stride) * 3)}.lg\:otp:has(>span:nth-child(4)){width:calc(var(--stride) * 4)}.lg\:otp:has(>span:nth-child(5)){width:calc(var(--stride) * 5)}.lg\:otp:has(>span:nth-child(6)){width:calc(var(--stride) * 6)}.lg\:otp:has(>span:nth-child(7)){width:calc(var(--stride) * 7)}.lg\:otp:has(>span:nth-child(8)){width:calc(var(--stride) * 8)}.lg\:otp>span{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inline-size:var(--otp-w);block-size:var(--otp-size);background-color:var(--color-base-100);border:var(--border) solid var(--input-color);border-radius:inherit;outline-offset:1px;box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;outline:2px solid #0000;transition:border-color .2s;display:flex;position:absolute;&:first-child{left:0;@supports (font:-apple-system-body){left:1px}}&:nth-child(2){left:calc(var(--stride) * 1);transition-delay:20ms}&:nth-child(3){left:calc(var(--stride) * 2);transition-delay:40ms}&:nth-child(4){left:calc(var(--stride) * 3);transition-delay:60ms}&:nth-child(5){left:calc(var(--stride) * 4);transition-delay:80ms}&:nth-child(6){left:calc(var(--stride) * 5);transition-delay:.1s}&:nth-child(7){left:calc(var(--stride) * 6);transition-delay:.12s}&:nth-child(8){left:calc(var(--stride) * 7);transition-delay:.14s}}.lg\:otp:has(input:valid:focus)>span{outline:2px solid var(--input-color);outline-offset:1px}.lg\:otp:has(input:valid:focus):after{opacity:0}.lg\:otp:after{content:"";width:var(--otp-w);height:var(--otp-size);border-radius:var(--radius-field);outline-offset:1px;z-index:10;outline:2px solid #0000;flex-shrink:0;margin-inline-start:calc(-1 * (var(--otp-gap) + var(--otp-ch) * .5))}.lg\:otp:focus-within{--input-color:var(--color-base-content);&>span{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}&:after{outline-color:var(--input-color)}}.lg\:otp:has(>input:disabled){cursor:not-allowed;&>input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}&>span{border-color:var(--color-base-200);background-color:var(--color-base-200);box-shadow:none}}}.lg\:otp-joined{--otp-gap:0rem;clip-path:inset(-3.5px 0 -3.5px -3.5px);&>span{&:first-of-type{border-start-end-radius:0;border-end-end-radius:0}&:not(&:last-of-type){border-inline-end-style:var(--tw-border-style);border-inline-end-width:0}&:not(&:first-of-type,&:last-of-type){border-radius:0}&:last-of-type{border-start-start-radius:0;border-end-start-radius:0}}&:after{outline-offset:calc((-1 * var(--border)) - 3px);@supports (font:-apple-system-body){--otp-w:calc(var(--otp-ch) * 2 - 1px)}}&:has(input:valid:focus){&>span{outline-offset:calc((-1 * var(--border)) - 3px)}}}@layer daisyui.l1.l2{.lg\:otp-xs{--otp-size:calc(var(--size-field,.25rem) * 6);font-size:1.25rem}.lg\:otp-sm{--otp-size:calc(var(--size-field,.25rem) * 8);font-size:1.5rem}.lg\:otp-md{--otp-size:calc(var(--size-field,.25rem) * 10);font-size:1.75rem}.lg\:otp-lg{--otp-size:calc(var(--size-field,.25rem) * 12);font-size:2rem}.lg\:otp-xl{--otp-size:calc(var(--size-field,.25rem) * 14);font-size:2.5rem}.otp-neutral,.lg\:otp-neutral:focus,.lg\:otp-neutral:focus-within{--input-color:var(--color-neutral)}.otp-primary,.lg\:otp-primary:focus,.lg\:otp-primary:focus-within{--input-color:var(--color-primary)}.otp-secondary,.lg\:otp-secondary:focus,.lg\:otp-secondary:focus-within{--input-color:var(--color-secondary)}.otp-accent,.lg\:otp-accent:focus,.lg\:otp-accent:focus-within{--input-color:var(--color-accent)}.otp-info,.lg\:otp-info:focus,.lg\:otp-info:focus-within{--input-color:var(--color-info)}.otp-success,.lg\:otp-success:focus,.lg\:otp-success:focus-within{--input-color:var(--color-success)}.otp-warning,.lg\:otp-warning:focus,.lg\:otp-warning:focus-within{--input-color:var(--color-warning)}.otp-error,.lg\:otp-error:focus,.lg\:otp-error:focus-within{--input-color:var(--color-error)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:otp{font-family:var(--font-mono,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);clip-path:inset(-3.5px 3.5px -3.5px -3.5px);border-radius:var(--radius-field);gap:var(--otp-gap);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);--otp-ch:1ch;direction:ltr;font-size:1.75rem;display:inline-flex;position:relative}@supports (font:-apple-system-body){.xl\:otp{--otp-ch:.618164em}}.xl\:otp{--otp-gap:calc(var(--otp-ch) * .5);--otp-w:calc(var(--otp-ch) * 2);--otp-size:calc(var(--size-field,.25rem) * 10);--stride:calc(var(--otp-w) + var(--otp-gap))}.xl\:otp>input{pointer-events:none;z-index:1;appearance:none;border-style:var(--tw-border-style);outline-style:var(--tw-outline-style);field-sizing:content;text-indent:1px;letter-spacing:calc(var(--stride) - var(--otp-ch));font-variant-numeric:tabular-nums;background-color:#0000;border-width:0;outline-width:0;margin:0;padding:0;padding-inline-start:calc(var(--otp-ch) * .5 - 1px);line-height:1;inset-inline-start:0;&::selection{color:color-mix(in oklab, var(--color-base-content) 20%, #0000);background-color:#0000}&:valid{caret-color:#0000}}.xl\:otp:has(>span:first-child){width:calc(var(--stride) * 1)}.xl\:otp:has(>span:nth-child(2)){width:calc(var(--stride) * 2)}.xl\:otp:has(>span:nth-child(3)){width:calc(var(--stride) * 3)}.xl\:otp:has(>span:nth-child(4)){width:calc(var(--stride) * 4)}.xl\:otp:has(>span:nth-child(5)){width:calc(var(--stride) * 5)}.xl\:otp:has(>span:nth-child(6)){width:calc(var(--stride) * 6)}.xl\:otp:has(>span:nth-child(7)){width:calc(var(--stride) * 7)}.xl\:otp:has(>span:nth-child(8)){width:calc(var(--stride) * 8)}.xl\:otp>span{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inline-size:var(--otp-w);block-size:var(--otp-size);background-color:var(--color-base-100);border:var(--border) solid var(--input-color);border-radius:inherit;outline-offset:1px;box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;outline:2px solid #0000;transition:border-color .2s;display:flex;position:absolute;&:first-child{left:0;@supports (font:-apple-system-body){left:1px}}&:nth-child(2){left:calc(var(--stride) * 1);transition-delay:20ms}&:nth-child(3){left:calc(var(--stride) * 2);transition-delay:40ms}&:nth-child(4){left:calc(var(--stride) * 3);transition-delay:60ms}&:nth-child(5){left:calc(var(--stride) * 4);transition-delay:80ms}&:nth-child(6){left:calc(var(--stride) * 5);transition-delay:.1s}&:nth-child(7){left:calc(var(--stride) * 6);transition-delay:.12s}&:nth-child(8){left:calc(var(--stride) * 7);transition-delay:.14s}}.xl\:otp:has(input:valid:focus)>span{outline:2px solid var(--input-color);outline-offset:1px}.xl\:otp:has(input:valid:focus):after{opacity:0}.xl\:otp:after{content:"";width:var(--otp-w);height:var(--otp-size);border-radius:var(--radius-field);outline-offset:1px;z-index:10;outline:2px solid #0000;flex-shrink:0;margin-inline-start:calc(-1 * (var(--otp-gap) + var(--otp-ch) * .5))}.xl\:otp:focus-within{--input-color:var(--color-base-content);&>span{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}&:after{outline-color:var(--input-color)}}.xl\:otp:has(>input:disabled){cursor:not-allowed;&>input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}&>span{border-color:var(--color-base-200);background-color:var(--color-base-200);box-shadow:none}}}.xl\:otp-joined{--otp-gap:0rem;clip-path:inset(-3.5px 0 -3.5px -3.5px);&>span{&:first-of-type{border-start-end-radius:0;border-end-end-radius:0}&:not(&:last-of-type){border-inline-end-style:var(--tw-border-style);border-inline-end-width:0}&:not(&:first-of-type,&:last-of-type){border-radius:0}&:last-of-type{border-start-start-radius:0;border-end-start-radius:0}}&:after{outline-offset:calc((-1 * var(--border)) - 3px);@supports (font:-apple-system-body){--otp-w:calc(var(--otp-ch) * 2 - 1px)}}&:has(input:valid:focus){&>span{outline-offset:calc((-1 * var(--border)) - 3px)}}}@layer daisyui.l1.l2{.xl\:otp-xs{--otp-size:calc(var(--size-field,.25rem) * 6);font-size:1.25rem}.xl\:otp-sm{--otp-size:calc(var(--size-field,.25rem) * 8);font-size:1.5rem}.xl\:otp-md{--otp-size:calc(var(--size-field,.25rem) * 10);font-size:1.75rem}.xl\:otp-lg{--otp-size:calc(var(--size-field,.25rem) * 12);font-size:2rem}.xl\:otp-xl{--otp-size:calc(var(--size-field,.25rem) * 14);font-size:2.5rem}.otp-neutral,.xl\:otp-neutral:focus,.xl\:otp-neutral:focus-within{--input-color:var(--color-neutral)}.otp-primary,.xl\:otp-primary:focus,.xl\:otp-primary:focus-within{--input-color:var(--color-primary)}.otp-secondary,.xl\:otp-secondary:focus,.xl\:otp-secondary:focus-within{--input-color:var(--color-secondary)}.otp-accent,.xl\:otp-accent:focus,.xl\:otp-accent:focus-within{--input-color:var(--color-accent)}.otp-info,.xl\:otp-info:focus,.xl\:otp-info:focus-within{--input-color:var(--color-info)}.otp-success,.xl\:otp-success:focus,.xl\:otp-success:focus-within{--input-color:var(--color-success)}.otp-warning,.xl\:otp-warning:focus,.xl\:otp-warning:focus-within{--input-color:var(--color-warning)}.otp-error,.xl\:otp-error:focus,.xl\:otp-error:focus-within{--input-color:var(--color-error)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:otp{font-family:var(--font-mono,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);clip-path:inset(-3.5px 3.5px -3.5px -3.5px);border-radius:var(--radius-field);gap:var(--otp-gap);--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);--otp-ch:1ch;direction:ltr;font-size:1.75rem;display:inline-flex;position:relative}@supports (font:-apple-system-body){.\32 xl\:otp{--otp-ch:.618164em}}.\32 xl\:otp{--otp-gap:calc(var(--otp-ch) * .5);--otp-w:calc(var(--otp-ch) * 2);--otp-size:calc(var(--size-field,.25rem) * 10);--stride:calc(var(--otp-w) + var(--otp-gap))}.\32 xl\:otp>input{pointer-events:none;z-index:1;appearance:none;border-style:var(--tw-border-style);outline-style:var(--tw-outline-style);field-sizing:content;text-indent:1px;letter-spacing:calc(var(--stride) - var(--otp-ch));font-variant-numeric:tabular-nums;background-color:#0000;border-width:0;outline-width:0;margin:0;padding:0;padding-inline-start:calc(var(--otp-ch) * .5 - 1px);line-height:1;inset-inline-start:0;&::selection{color:color-mix(in oklab, var(--color-base-content) 20%, #0000);background-color:#0000}&:valid{caret-color:#0000}}.\32 xl\:otp:has(>span:first-child){width:calc(var(--stride) * 1)}.\32 xl\:otp:has(>span:nth-child(2)){width:calc(var(--stride) * 2)}.\32 xl\:otp:has(>span:nth-child(3)){width:calc(var(--stride) * 3)}.\32 xl\:otp:has(>span:nth-child(4)){width:calc(var(--stride) * 4)}.\32 xl\:otp:has(>span:nth-child(5)){width:calc(var(--stride) * 5)}.\32 xl\:otp:has(>span:nth-child(6)){width:calc(var(--stride) * 6)}.\32 xl\:otp:has(>span:nth-child(7)){width:calc(var(--stride) * 7)}.\32 xl\:otp:has(>span:nth-child(8)){width:calc(var(--stride) * 8)}.\32 xl\:otp>span{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));inline-size:var(--otp-w);block-size:var(--otp-size);background-color:var(--color-base-100);border:var(--border) solid var(--input-color);border-radius:inherit;outline-offset:1px;box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;outline:2px solid #0000;transition:border-color .2s;display:flex;position:absolute;&:first-child{left:0;@supports (font:-apple-system-body){left:1px}}&:nth-child(2){left:calc(var(--stride) * 1);transition-delay:20ms}&:nth-child(3){left:calc(var(--stride) * 2);transition-delay:40ms}&:nth-child(4){left:calc(var(--stride) * 3);transition-delay:60ms}&:nth-child(5){left:calc(var(--stride) * 4);transition-delay:80ms}&:nth-child(6){left:calc(var(--stride) * 5);transition-delay:.1s}&:nth-child(7){left:calc(var(--stride) * 6);transition-delay:.12s}&:nth-child(8){left:calc(var(--stride) * 7);transition-delay:.14s}}.\32 xl\:otp:has(input:valid:focus)>span{outline:2px solid var(--input-color);outline-offset:1px}.\32 xl\:otp:has(input:valid:focus):after{opacity:0}.\32 xl\:otp:after{content:"";width:var(--otp-w);height:var(--otp-size);border-radius:var(--radius-field);outline-offset:1px;z-index:10;outline:2px solid #0000;flex-shrink:0;margin-inline-start:calc(-1 * (var(--otp-gap) + var(--otp-ch) * .5))}.\32 xl\:otp:focus-within{--input-color:var(--color-base-content);&>span{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}&:after{outline-color:var(--input-color)}}.\32 xl\:otp:has(>input:disabled){cursor:not-allowed;&>input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}&>span{border-color:var(--color-base-200);background-color:var(--color-base-200);box-shadow:none}}}.\32 xl\:otp-joined{--otp-gap:0rem;clip-path:inset(-3.5px 0 -3.5px -3.5px);&>span{&:first-of-type{border-start-end-radius:0;border-end-end-radius:0}&:not(&:last-of-type){border-inline-end-style:var(--tw-border-style);border-inline-end-width:0}&:not(&:first-of-type,&:last-of-type){border-radius:0}&:last-of-type{border-start-start-radius:0;border-end-start-radius:0}}&:after{outline-offset:calc((-1 * var(--border)) - 3px);@supports (font:-apple-system-body){--otp-w:calc(var(--otp-ch) * 2 - 1px)}}&:has(input:valid:focus){&>span{outline-offset:calc((-1 * var(--border)) - 3px)}}}@layer daisyui.l1.l2{.\32 xl\:otp-xs{--otp-size:calc(var(--size-field,.25rem) * 6);font-size:1.25rem}.\32 xl\:otp-sm{--otp-size:calc(var(--size-field,.25rem) * 8);font-size:1.5rem}.\32 xl\:otp-md{--otp-size:calc(var(--size-field,.25rem) * 10);font-size:1.75rem}.\32 xl\:otp-lg{--otp-size:calc(var(--size-field,.25rem) * 12);font-size:2rem}.\32 xl\:otp-xl{--otp-size:calc(var(--size-field,.25rem) * 14);font-size:2.5rem}.otp-neutral,.\32 xl\:otp-neutral:focus,.\32 xl\:otp-neutral:focus-within{--input-color:var(--color-neutral)}.otp-primary,.\32 xl\:otp-primary:focus,.\32 xl\:otp-primary:focus-within{--input-color:var(--color-primary)}.otp-secondary,.\32 xl\:otp-secondary:focus,.\32 xl\:otp-secondary:focus-within{--input-color:var(--color-secondary)}.otp-accent,.\32 xl\:otp-accent:focus,.\32 xl\:otp-accent:focus-within{--input-color:var(--color-accent)}.otp-info,.\32 xl\:otp-info:focus,.\32 xl\:otp-info:focus-within{--input-color:var(--color-info)}.otp-success,.\32 xl\:otp-success:focus,.\32 xl\:otp-success:focus-within{--input-color:var(--color-success)}.otp-warning,.\32 xl\:otp-warning:focus,.\32 xl\:otp-warning:focus-within{--input-color:var(--color-warning)}.otp-error,.\32 xl\:otp-error:focus,.\32 xl\:otp-error:focus-within{--input-color:var(--color-error)}}}.alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200));@layer daisyui.l1.l2.l3{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 33%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid;&:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}}@media (width>=640px){.sm\:alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200));@layer daisyui.l1.l2.l3{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 33%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid;&:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}}@layer daisyui.l1.l2{.sm\:alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.sm\:alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.sm\:alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.sm\:alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.sm\:alert-vertical{text-align:center;grid-template-columns:auto;grid-auto-flow:row;justify-content:center;justify-items:center}.sm\:alert-vertical:has(>:nth-child(2)){grid-template-columns:auto}.sm\:alert-horizontal{text-align:start;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;justify-items:start}.sm\:alert-horizontal:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}@layer daisyui.l1{.sm\:alert-soft{color:var(--alert-color,var(--color-base-content));background:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 8%, var(--color-base-100));--alert-border-color:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 10%, var(--color-base-100));box-shadow:none;background-image:none}.sm\:alert-outline{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none}.sm\:alert-dash{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none;border-style:dashed}}}@media (width>=768px){.md\:alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200));@layer daisyui.l1.l2.l3{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 33%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid;&:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}}@layer daisyui.l1.l2{.md\:alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.md\:alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.md\:alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.md\:alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.md\:alert-vertical{text-align:center;grid-template-columns:auto;grid-auto-flow:row;justify-content:center;justify-items:center}.md\:alert-vertical:has(>:nth-child(2)){grid-template-columns:auto}.md\:alert-horizontal{text-align:start;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;justify-items:start}.md\:alert-horizontal:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}@layer daisyui.l1{.md\:alert-soft{color:var(--alert-color,var(--color-base-content));background:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 8%, var(--color-base-100));--alert-border-color:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 10%, var(--color-base-100));box-shadow:none;background-image:none}.md\:alert-outline{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none}.md\:alert-dash{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none;border-style:dashed}}}@media (width>=1024px){.lg\:alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200));@layer daisyui.l1.l2.l3{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 33%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid;&:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}}@layer daisyui.l1.l2{.lg\:alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.lg\:alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.lg\:alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.lg\:alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.lg\:alert-vertical{text-align:center;grid-template-columns:auto;grid-auto-flow:row;justify-content:center;justify-items:center}.lg\:alert-vertical:has(>:nth-child(2)){grid-template-columns:auto}.lg\:alert-horizontal{text-align:start;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;justify-items:start}.lg\:alert-horizontal:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}@layer daisyui.l1{.lg\:alert-soft{color:var(--alert-color,var(--color-base-content));background:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 8%, var(--color-base-100));--alert-border-color:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 10%, var(--color-base-100));box-shadow:none;background-image:none}.lg\:alert-outline{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none}.lg\:alert-dash{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none;border-style:dashed}}}@media (width>=1280px){.xl\:alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200));@layer daisyui.l1.l2.l3{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 33%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid;&:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}}@layer daisyui.l1.l2{.xl\:alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.xl\:alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.xl\:alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.xl\:alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.xl\:alert-vertical{text-align:center;grid-template-columns:auto;grid-auto-flow:row;justify-content:center;justify-items:center}.xl\:alert-vertical:has(>:nth-child(2)){grid-template-columns:auto}.xl\:alert-horizontal{text-align:start;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;justify-items:start}.xl\:alert-horizontal:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}@layer daisyui.l1{.xl\:alert-soft{color:var(--alert-color,var(--color-base-content));background:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 8%, var(--color-base-100));--alert-border-color:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 10%, var(--color-base-100));box-shadow:none;background-image:none}.xl\:alert-outline{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none}.xl\:alert-dash{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none;border-style:dashed}}}@media (width>=1536px){.\32 xl\:alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200));@layer daisyui.l1.l2.l3{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 33%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid;&:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}}@layer daisyui.l1.l2{.\32 xl\:alert-info{color:var(--color-info-content);--alert-border-color:var(--color-info);--alert-color:var(--color-info)}.\32 xl\:alert-success{color:var(--color-success-content);--alert-border-color:var(--color-success);--alert-color:var(--color-success)}.\32 xl\:alert-warning{color:var(--color-warning-content);--alert-border-color:var(--color-warning);--alert-color:var(--color-warning)}.\32 xl\:alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.\32 xl\:alert-vertical{text-align:center;grid-template-columns:auto;grid-auto-flow:row;justify-content:center;justify-items:center}.\32 xl\:alert-vertical:has(>:nth-child(2)){grid-template-columns:auto}.\32 xl\:alert-horizontal{text-align:start;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;justify-items:start}.\32 xl\:alert-horizontal:has(>:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}}@layer daisyui.l1{.\32 xl\:alert-soft{color:var(--alert-color,var(--color-base-content));background:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 8%, var(--color-base-100));--alert-border-color:color-mix(in oklab, var(--alert-color,var(--color-base-content)) 10%, var(--color-base-100));box-shadow:none;background-image:none}.\32 xl\:alert-outline{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none}.\32 xl\:alert-dash{color:var(--alert-color);box-shadow:none;background-color:#0000;background-image:none;border-style:dashed}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent);vertical-align:middle;color:color-mix(in oklab, var(--color-black) 30%, transparent);--size:calc(var(--size-selector,.25rem) * 2);width:var(--size);height:var(--size);background-position:50%;background-repeat:no-repeat;background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);display:inline-block}}@layer daisyui.l1.l2{.sm\:status-primary{background-color:var(--color-primary);color:var(--color-primary)}.sm\:status-secondary{background-color:var(--color-secondary);color:var(--color-secondary)}.sm\:status-accent{background-color:var(--color-accent);color:var(--color-accent)}.sm\:status-neutral{background-color:var(--color-neutral);color:var(--color-neutral)}.sm\:status-info{background-color:var(--color-info);color:var(--color-info)}.sm\:status-success{background-color:var(--color-success);color:var(--color-success)}.sm\:status-warning{background-color:var(--color-warning);color:var(--color-warning)}.sm\:status-error{background-color:var(--color-error);color:var(--color-error)}.sm\:status-xs{--size:calc(var(--size-selector,.25rem) * .5)}.sm\:status-sm{--size:calc(var(--size-selector,.25rem) * 1)}.sm\:status-md{--size:calc(var(--size-selector,.25rem) * 2)}.sm\:status-lg{--size:calc(var(--size-selector,.25rem) * 3)}.sm\:status-xl{--size:calc(var(--size-selector,.25rem) * 4)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent);vertical-align:middle;color:color-mix(in oklab, var(--color-black) 30%, transparent);--size:calc(var(--size-selector,.25rem) * 2);width:var(--size);height:var(--size);background-position:50%;background-repeat:no-repeat;background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);display:inline-block}}@layer daisyui.l1.l2{.md\:status-primary{background-color:var(--color-primary);color:var(--color-primary)}.md\:status-secondary{background-color:var(--color-secondary);color:var(--color-secondary)}.md\:status-accent{background-color:var(--color-accent);color:var(--color-accent)}.md\:status-neutral{background-color:var(--color-neutral);color:var(--color-neutral)}.md\:status-info{background-color:var(--color-info);color:var(--color-info)}.md\:status-success{background-color:var(--color-success);color:var(--color-success)}.md\:status-warning{background-color:var(--color-warning);color:var(--color-warning)}.md\:status-error{background-color:var(--color-error);color:var(--color-error)}.md\:status-xs{--size:calc(var(--size-selector,.25rem) * .5)}.md\:status-sm{--size:calc(var(--size-selector,.25rem) * 1)}.md\:status-md{--size:calc(var(--size-selector,.25rem) * 2)}.md\:status-lg{--size:calc(var(--size-selector,.25rem) * 3)}.md\:status-xl{--size:calc(var(--size-selector,.25rem) * 4)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent);vertical-align:middle;color:color-mix(in oklab, var(--color-black) 30%, transparent);--size:calc(var(--size-selector,.25rem) * 2);width:var(--size);height:var(--size);background-position:50%;background-repeat:no-repeat;background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);display:inline-block}}@layer daisyui.l1.l2{.lg\:status-primary{background-color:var(--color-primary);color:var(--color-primary)}.lg\:status-secondary{background-color:var(--color-secondary);color:var(--color-secondary)}.lg\:status-accent{background-color:var(--color-accent);color:var(--color-accent)}.lg\:status-neutral{background-color:var(--color-neutral);color:var(--color-neutral)}.lg\:status-info{background-color:var(--color-info);color:var(--color-info)}.lg\:status-success{background-color:var(--color-success);color:var(--color-success)}.lg\:status-warning{background-color:var(--color-warning);color:var(--color-warning)}.lg\:status-error{background-color:var(--color-error);color:var(--color-error)}.lg\:status-xs{--size:calc(var(--size-selector,.25rem) * .5)}.lg\:status-sm{--size:calc(var(--size-selector,.25rem) * 1)}.lg\:status-md{--size:calc(var(--size-selector,.25rem) * 2)}.lg\:status-lg{--size:calc(var(--size-selector,.25rem) * 3)}.lg\:status-xl{--size:calc(var(--size-selector,.25rem) * 4)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent);vertical-align:middle;color:color-mix(in oklab, var(--color-black) 30%, transparent);--size:calc(var(--size-selector,.25rem) * 2);width:var(--size);height:var(--size);background-position:50%;background-repeat:no-repeat;background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);display:inline-block}}@layer daisyui.l1.l2{.xl\:status-primary{background-color:var(--color-primary);color:var(--color-primary)}.xl\:status-secondary{background-color:var(--color-secondary);color:var(--color-secondary)}.xl\:status-accent{background-color:var(--color-accent);color:var(--color-accent)}.xl\:status-neutral{background-color:var(--color-neutral);color:var(--color-neutral)}.xl\:status-info{background-color:var(--color-info);color:var(--color-info)}.xl\:status-success{background-color:var(--color-success);color:var(--color-success)}.xl\:status-warning{background-color:var(--color-warning);color:var(--color-warning)}.xl\:status-error{background-color:var(--color-error);color:var(--color-error)}.xl\:status-xs{--size:calc(var(--size-selector,.25rem) * .5)}.xl\:status-sm{--size:calc(var(--size-selector,.25rem) * 1)}.xl\:status-md{--size:calc(var(--size-selector,.25rem) * 2)}.xl\:status-lg{--size:calc(var(--size-selector,.25rem) * 3)}.xl\:status-xl{--size:calc(var(--size-selector,.25rem) * 4)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent);vertical-align:middle;color:color-mix(in oklab, var(--color-black) 30%, transparent);--size:calc(var(--size-selector,.25rem) * 2);width:var(--size);height:var(--size);background-position:50%;background-repeat:no-repeat;background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000);display:inline-block}}@layer daisyui.l1.l2{.\32 xl\:status-primary{background-color:var(--color-primary);color:var(--color-primary)}.\32 xl\:status-secondary{background-color:var(--color-secondary);color:var(--color-secondary)}.\32 xl\:status-accent{background-color:var(--color-accent);color:var(--color-accent)}.\32 xl\:status-neutral{background-color:var(--color-neutral);color:var(--color-neutral)}.\32 xl\:status-info{background-color:var(--color-info);color:var(--color-info)}.\32 xl\:status-success{background-color:var(--color-success);color:var(--color-success)}.\32 xl\:status-warning{background-color:var(--color-warning);color:var(--color-warning)}.\32 xl\:status-error{background-color:var(--color-error);color:var(--color-error)}.\32 xl\:status-xs{--size:calc(var(--size-selector,.25rem) * .5)}.\32 xl\:status-sm{--size:calc(var(--size-selector,.25rem) * 1)}.\32 xl\:status-md{--size:calc(var(--size-selector,.25rem) * 2)}.\32 xl\:status-lg{--size:calc(var(--size-selector,.25rem) * 3)}.\32 xl\:status-xl{--size:calc(var(--size-selector,.25rem) * 4)}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:megamenu{width:unset;border-radius:calc(var(--radius-field) + var(--border));--mm-anchor:--mm1;--size:calc(var(--size-field,.25rem) * 10);background-color:#0000;align-items:center;display:flex;position:relative;overflow:visible}.sm\:megamenu [popovertarget]{isolation:isolate;cursor:pointer;padding-inline:var(--mm-p,1rem);height:var(--size);font-size:var(--fontsize,.875rem);border:var(--border) solid transparent;anchor-name:var(--mm-anchor);align-items:center;gap:.75rem;transition:background-color .2s ease-out,color .2s ease-out;display:flex;position:relative;&:focus-visible{outline:2px solid var(--color-base-content);outline-offset:2px}&:after{--tw-content:"";content:var(--tw-content);pointer-events:none;opacity:.25;width:.375rem;height:.375rem;transition:opacity .2s ease-out,rotate .2s ease-out;display:block;inset-inline-end:1.4rem;rotate:-135deg;box-shadow:inset 2px 2px}&:first-of-type{--mm-anchor:--mm1}&:nth-of-type(2){--mm-anchor:--mm2}&:nth-of-type(3){--mm-anchor:--mm3}&:nth-of-type(4){--mm-anchor:--mm4}&:nth-of-type(5){--mm-anchor:--mm5}&:nth-of-type(6){--mm-anchor:--mm6}&:nth-of-type(7){--mm-anchor:--mm7}&:nth-of-type(8){--mm-anchor:--mm8}&:nth-of-type(9){--mm-anchor:--mm9}&:nth-of-type(10){--mm-anchor:--mm10}}.sm\:megamenu:not(:has([popovertarget]:hover,[popover]:popover-open)) .megamenu-active{background-color:#0000}.sm\:megamenu:has([popovertarget]:hover):not(:has([popover]:popover-open)) .megamenu-active,.sm\:megamenu:has([popover]:popover-open) .megamenu-active{inset:anchor(var(--mm-anchor) top) anchor(var(--mm-anchor) end) anchor(var(--mm-anchor) bottom) anchor(var(--mm-anchor) start)}.sm\:megamenu:has([popovertarget]:first-of-type:hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:first-of-type:popover-open){--mm-anchor:--mm1}.sm\:megamenu:has([popovertarget]:nth-of-type(2):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(2):popover-open){--mm-anchor:--mm2}.sm\:megamenu:has([popovertarget]:nth-of-type(3):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(3):popover-open){--mm-anchor:--mm3}.sm\:megamenu:has([popovertarget]:nth-of-type(4):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(4):popover-open){--mm-anchor:--mm4}.sm\:megamenu:has([popovertarget]:nth-of-type(5):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(5):popover-open){--mm-anchor:--mm5}.sm\:megamenu:has([popovertarget]:nth-of-type(6):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(6):popover-open){--mm-anchor:--mm6}.sm\:megamenu:has([popovertarget]:nth-of-type(7):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(7):popover-open){--mm-anchor:--mm7}.sm\:megamenu:has([popovertarget]:nth-of-type(8):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(8):popover-open){--mm-anchor:--mm8}.sm\:megamenu:has([popovertarget]:nth-of-type(9):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(9):popover-open){--mm-anchor:--mm9}.sm\:megamenu:has([popovertarget]:nth-of-type(10):hover):not(:has([popover]:popover-open)),.sm\:megamenu:has([popover]:nth-of-type(10):popover-open){--mm-anchor:--mm10}.sm\:megamenu [popovertarget]:has(+[popover]:popover-open):after{opacity:1;rotate:45deg}.sm\:megamenu [popover]{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-base-300);opacity:0;border-radius:var(--radius-box);background-color:var(--color-base-100);position-area:block-end span-inline-end;max-height:100dvh;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;margin-top:.25rem;translate:0 -.5rem;scale:.98;&:popover-open{opacity:1;@starting-style{opacity:0}}}.sm\:megamenu:has([popover]:popover-open) [popover]{translate:0;scale:1}.sm\:megamenu-active{pointer-events:none;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);border-radius:var(--radius-field);transition:inset .3s linear(0, .5 10%, .9 30%, 1.05 50%, 1.1 75%, 1), background-color .2s ease-out;position:absolute}}@layer daisyui.l1{.sm\:megamenu:popover-open{opacity:1;background-color:var(--color-base-100);width:100%;margin-top:var(--mm-mt,4rem);border-radius:0;flex-direction:column;align-items:flex-start;padding-top:1rem;position:fixed;overflow-y:scroll;& [popovertarget],&>[popover]:not(:first-of-type){display:var(--mm-display,none)}&::backdrop{background-color:var(--mm-backdrop,oklch(0% 0 0/.4))}position-area:block-end;top:0;max-block-size:80svh;transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;inset-inline-start:0;translate:0;scale:1;@starting-style{opacity:0}& [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}& [popover]{opacity:1;background-color:#0000;border:none;max-height:none;display:block;position:relative;overflow:visible}}}@layer daisyui.l1.l2{.sm\:megamenu-wide{anchor-name:--megamenu}.sm\:megamenu-wide:popover-open{inset-inline:0}.sm\:megamenu-wide [popover]{position-area:block-end;position-anchor:--megamenu;width:anchor-size(inline)}.sm\:megamenu-full{anchor-name:--megamenu}.sm\:megamenu-full:popover-open{inset-inline:0}.sm\:megamenu-full [popover]{position-area:block-end;position-anchor:--megamenu;border-inline-width:0;border-radius:0;width:100%}.sm\:megamenu-vertical{opacity:0;background-color:var(--color-base-100);position-area:block-end;width:100%;--mm-backdrop:transparent;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;border-radius:0;flex-direction:column;align-items:flex-start;position:relative;translate:0 -.5rem;scale:.98}.sm\:megamenu-vertical:not([popover]:popover-open){position:revert;display:none}.sm\:megamenu-vertical:popover-open{--mm-mt:0;--mm-display:block}.sm\:megamenu-vertical [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}.sm\:megamenu-vertical [popover]{opacity:1;background-color:#0000;border:none;display:block;position:relative;overflow:visible}.sm\:megamenu-xs{--fontsize:.6875rem;--mm-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.sm\:megamenu-sm{--fontsize:.75rem;--mm-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.sm\:megamenu-md{--fontsize:.875rem;--mm-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.sm\:megamenu-lg{--fontsize:1.125rem;--mm-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.sm\:megamenu-xl{--fontsize:1.375rem;--mm-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:megamenu{width:unset;border-radius:calc(var(--radius-field) + var(--border));--mm-anchor:--mm1;--size:calc(var(--size-field,.25rem) * 10);background-color:#0000;align-items:center;display:flex;position:relative;overflow:visible}.md\:megamenu [popovertarget]{isolation:isolate;cursor:pointer;padding-inline:var(--mm-p,1rem);height:var(--size);font-size:var(--fontsize,.875rem);border:var(--border) solid transparent;anchor-name:var(--mm-anchor);align-items:center;gap:.75rem;transition:background-color .2s ease-out,color .2s ease-out;display:flex;position:relative;&:focus-visible{outline:2px solid var(--color-base-content);outline-offset:2px}&:after{--tw-content:"";content:var(--tw-content);pointer-events:none;opacity:.25;width:.375rem;height:.375rem;transition:opacity .2s ease-out,rotate .2s ease-out;display:block;inset-inline-end:1.4rem;rotate:-135deg;box-shadow:inset 2px 2px}&:first-of-type{--mm-anchor:--mm1}&:nth-of-type(2){--mm-anchor:--mm2}&:nth-of-type(3){--mm-anchor:--mm3}&:nth-of-type(4){--mm-anchor:--mm4}&:nth-of-type(5){--mm-anchor:--mm5}&:nth-of-type(6){--mm-anchor:--mm6}&:nth-of-type(7){--mm-anchor:--mm7}&:nth-of-type(8){--mm-anchor:--mm8}&:nth-of-type(9){--mm-anchor:--mm9}&:nth-of-type(10){--mm-anchor:--mm10}}.md\:megamenu:not(:has([popovertarget]:hover,[popover]:popover-open)) .megamenu-active{background-color:#0000}.md\:megamenu:has([popovertarget]:hover):not(:has([popover]:popover-open)) .megamenu-active,.md\:megamenu:has([popover]:popover-open) .megamenu-active{inset:anchor(var(--mm-anchor) top) anchor(var(--mm-anchor) end) anchor(var(--mm-anchor) bottom) anchor(var(--mm-anchor) start)}.md\:megamenu:has([popovertarget]:first-of-type:hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:first-of-type:popover-open){--mm-anchor:--mm1}.md\:megamenu:has([popovertarget]:nth-of-type(2):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(2):popover-open){--mm-anchor:--mm2}.md\:megamenu:has([popovertarget]:nth-of-type(3):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(3):popover-open){--mm-anchor:--mm3}.md\:megamenu:has([popovertarget]:nth-of-type(4):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(4):popover-open){--mm-anchor:--mm4}.md\:megamenu:has([popovertarget]:nth-of-type(5):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(5):popover-open){--mm-anchor:--mm5}.md\:megamenu:has([popovertarget]:nth-of-type(6):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(6):popover-open){--mm-anchor:--mm6}.md\:megamenu:has([popovertarget]:nth-of-type(7):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(7):popover-open){--mm-anchor:--mm7}.md\:megamenu:has([popovertarget]:nth-of-type(8):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(8):popover-open){--mm-anchor:--mm8}.md\:megamenu:has([popovertarget]:nth-of-type(9):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(9):popover-open){--mm-anchor:--mm9}.md\:megamenu:has([popovertarget]:nth-of-type(10):hover):not(:has([popover]:popover-open)),.md\:megamenu:has([popover]:nth-of-type(10):popover-open){--mm-anchor:--mm10}.md\:megamenu [popovertarget]:has(+[popover]:popover-open):after{opacity:1;rotate:45deg}.md\:megamenu [popover]{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-base-300);opacity:0;border-radius:var(--radius-box);background-color:var(--color-base-100);position-area:block-end span-inline-end;max-height:100dvh;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;margin-top:.25rem;translate:0 -.5rem;scale:.98;&:popover-open{opacity:1;@starting-style{opacity:0}}}.md\:megamenu:has([popover]:popover-open) [popover]{translate:0;scale:1}.md\:megamenu-active{pointer-events:none;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);border-radius:var(--radius-field);transition:inset .3s linear(0, .5 10%, .9 30%, 1.05 50%, 1.1 75%, 1), background-color .2s ease-out;position:absolute}}@layer daisyui.l1{.md\:megamenu:popover-open{opacity:1;background-color:var(--color-base-100);width:100%;margin-top:var(--mm-mt,4rem);border-radius:0;flex-direction:column;align-items:flex-start;padding-top:1rem;position:fixed;overflow-y:scroll;& [popovertarget],&>[popover]:not(:first-of-type){display:var(--mm-display,none)}&::backdrop{background-color:var(--mm-backdrop,oklch(0% 0 0/.4))}position-area:block-end;top:0;max-block-size:80svh;transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;inset-inline-start:0;translate:0;scale:1;@starting-style{opacity:0}& [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}& [popover]{opacity:1;background-color:#0000;border:none;max-height:none;display:block;position:relative;overflow:visible}}}@layer daisyui.l1.l2{.md\:megamenu-wide{anchor-name:--megamenu}.md\:megamenu-wide:popover-open{inset-inline:0}.md\:megamenu-wide [popover]{position-area:block-end;position-anchor:--megamenu;width:anchor-size(inline)}.md\:megamenu-full{anchor-name:--megamenu}.md\:megamenu-full:popover-open{inset-inline:0}.md\:megamenu-full [popover]{position-area:block-end;position-anchor:--megamenu;border-inline-width:0;border-radius:0;width:100%}.md\:megamenu-vertical{opacity:0;background-color:var(--color-base-100);position-area:block-end;width:100%;--mm-backdrop:transparent;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;border-radius:0;flex-direction:column;align-items:flex-start;position:relative;translate:0 -.5rem;scale:.98}.md\:megamenu-vertical:not([popover]:popover-open){position:revert;display:none}.md\:megamenu-vertical:popover-open{--mm-mt:0;--mm-display:block}.md\:megamenu-vertical [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}.md\:megamenu-vertical [popover]{opacity:1;background-color:#0000;border:none;display:block;position:relative;overflow:visible}.md\:megamenu-xs{--fontsize:.6875rem;--mm-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.md\:megamenu-sm{--fontsize:.75rem;--mm-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.md\:megamenu-md{--fontsize:.875rem;--mm-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.md\:megamenu-lg{--fontsize:1.125rem;--mm-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.md\:megamenu-xl{--fontsize:1.375rem;--mm-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:megamenu{width:unset;border-radius:calc(var(--radius-field) + var(--border));--mm-anchor:--mm1;--size:calc(var(--size-field,.25rem) * 10);background-color:#0000;align-items:center;display:flex;position:relative;overflow:visible}.lg\:megamenu [popovertarget]{isolation:isolate;cursor:pointer;padding-inline:var(--mm-p,1rem);height:var(--size);font-size:var(--fontsize,.875rem);border:var(--border) solid transparent;anchor-name:var(--mm-anchor);align-items:center;gap:.75rem;transition:background-color .2s ease-out,color .2s ease-out;display:flex;position:relative;&:focus-visible{outline:2px solid var(--color-base-content);outline-offset:2px}&:after{--tw-content:"";content:var(--tw-content);pointer-events:none;opacity:.25;width:.375rem;height:.375rem;transition:opacity .2s ease-out,rotate .2s ease-out;display:block;inset-inline-end:1.4rem;rotate:-135deg;box-shadow:inset 2px 2px}&:first-of-type{--mm-anchor:--mm1}&:nth-of-type(2){--mm-anchor:--mm2}&:nth-of-type(3){--mm-anchor:--mm3}&:nth-of-type(4){--mm-anchor:--mm4}&:nth-of-type(5){--mm-anchor:--mm5}&:nth-of-type(6){--mm-anchor:--mm6}&:nth-of-type(7){--mm-anchor:--mm7}&:nth-of-type(8){--mm-anchor:--mm8}&:nth-of-type(9){--mm-anchor:--mm9}&:nth-of-type(10){--mm-anchor:--mm10}}.lg\:megamenu:not(:has([popovertarget]:hover,[popover]:popover-open)) .megamenu-active{background-color:#0000}.lg\:megamenu:has([popovertarget]:hover):not(:has([popover]:popover-open)) .megamenu-active,.lg\:megamenu:has([popover]:popover-open) .megamenu-active{inset:anchor(var(--mm-anchor) top) anchor(var(--mm-anchor) end) anchor(var(--mm-anchor) bottom) anchor(var(--mm-anchor) start)}.lg\:megamenu:has([popovertarget]:first-of-type:hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:first-of-type:popover-open){--mm-anchor:--mm1}.lg\:megamenu:has([popovertarget]:nth-of-type(2):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(2):popover-open){--mm-anchor:--mm2}.lg\:megamenu:has([popovertarget]:nth-of-type(3):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(3):popover-open){--mm-anchor:--mm3}.lg\:megamenu:has([popovertarget]:nth-of-type(4):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(4):popover-open){--mm-anchor:--mm4}.lg\:megamenu:has([popovertarget]:nth-of-type(5):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(5):popover-open){--mm-anchor:--mm5}.lg\:megamenu:has([popovertarget]:nth-of-type(6):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(6):popover-open){--mm-anchor:--mm6}.lg\:megamenu:has([popovertarget]:nth-of-type(7):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(7):popover-open){--mm-anchor:--mm7}.lg\:megamenu:has([popovertarget]:nth-of-type(8):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(8):popover-open){--mm-anchor:--mm8}.lg\:megamenu:has([popovertarget]:nth-of-type(9):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(9):popover-open){--mm-anchor:--mm9}.lg\:megamenu:has([popovertarget]:nth-of-type(10):hover):not(:has([popover]:popover-open)),.lg\:megamenu:has([popover]:nth-of-type(10):popover-open){--mm-anchor:--mm10}.lg\:megamenu [popovertarget]:has(+[popover]:popover-open):after{opacity:1;rotate:45deg}.lg\:megamenu [popover]{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-base-300);opacity:0;border-radius:var(--radius-box);background-color:var(--color-base-100);position-area:block-end span-inline-end;max-height:100dvh;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;margin-top:.25rem;translate:0 -.5rem;scale:.98;&:popover-open{opacity:1;@starting-style{opacity:0}}}.lg\:megamenu:has([popover]:popover-open) [popover]{translate:0;scale:1}.lg\:megamenu-active{pointer-events:none;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);border-radius:var(--radius-field);transition:inset .3s linear(0, .5 10%, .9 30%, 1.05 50%, 1.1 75%, 1), background-color .2s ease-out;position:absolute}}@layer daisyui.l1{.lg\:megamenu:popover-open{opacity:1;background-color:var(--color-base-100);width:100%;margin-top:var(--mm-mt,4rem);border-radius:0;flex-direction:column;align-items:flex-start;padding-top:1rem;position:fixed;overflow-y:scroll;& [popovertarget],&>[popover]:not(:first-of-type){display:var(--mm-display,none)}&::backdrop{background-color:var(--mm-backdrop,oklch(0% 0 0/.4))}position-area:block-end;top:0;max-block-size:80svh;transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;inset-inline-start:0;translate:0;scale:1;@starting-style{opacity:0}& [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}& [popover]{opacity:1;background-color:#0000;border:none;max-height:none;display:block;position:relative;overflow:visible}}}@layer daisyui.l1.l2{.lg\:megamenu-wide{anchor-name:--megamenu}.lg\:megamenu-wide:popover-open{inset-inline:0}.lg\:megamenu-wide [popover]{position-area:block-end;position-anchor:--megamenu;width:anchor-size(inline)}.lg\:megamenu-full{anchor-name:--megamenu}.lg\:megamenu-full:popover-open{inset-inline:0}.lg\:megamenu-full [popover]{position-area:block-end;position-anchor:--megamenu;border-inline-width:0;border-radius:0;width:100%}.lg\:megamenu-vertical{opacity:0;background-color:var(--color-base-100);position-area:block-end;width:100%;--mm-backdrop:transparent;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;border-radius:0;flex-direction:column;align-items:flex-start;position:relative;translate:0 -.5rem;scale:.98}.lg\:megamenu-vertical:not([popover]:popover-open){position:revert;display:none}.lg\:megamenu-vertical:popover-open{--mm-mt:0;--mm-display:block}.lg\:megamenu-vertical [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}.lg\:megamenu-vertical [popover]{opacity:1;background-color:#0000;border:none;display:block;position:relative;overflow:visible}.lg\:megamenu-xs{--fontsize:.6875rem;--mm-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.lg\:megamenu-sm{--fontsize:.75rem;--mm-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.lg\:megamenu-md{--fontsize:.875rem;--mm-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.lg\:megamenu-lg{--fontsize:1.125rem;--mm-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.lg\:megamenu-xl{--fontsize:1.375rem;--mm-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:megamenu{width:unset;border-radius:calc(var(--radius-field) + var(--border));--mm-anchor:--mm1;--size:calc(var(--size-field,.25rem) * 10);background-color:#0000;align-items:center;display:flex;position:relative;overflow:visible}.xl\:megamenu [popovertarget]{isolation:isolate;cursor:pointer;padding-inline:var(--mm-p,1rem);height:var(--size);font-size:var(--fontsize,.875rem);border:var(--border) solid transparent;anchor-name:var(--mm-anchor);align-items:center;gap:.75rem;transition:background-color .2s ease-out,color .2s ease-out;display:flex;position:relative;&:focus-visible{outline:2px solid var(--color-base-content);outline-offset:2px}&:after{--tw-content:"";content:var(--tw-content);pointer-events:none;opacity:.25;width:.375rem;height:.375rem;transition:opacity .2s ease-out,rotate .2s ease-out;display:block;inset-inline-end:1.4rem;rotate:-135deg;box-shadow:inset 2px 2px}&:first-of-type{--mm-anchor:--mm1}&:nth-of-type(2){--mm-anchor:--mm2}&:nth-of-type(3){--mm-anchor:--mm3}&:nth-of-type(4){--mm-anchor:--mm4}&:nth-of-type(5){--mm-anchor:--mm5}&:nth-of-type(6){--mm-anchor:--mm6}&:nth-of-type(7){--mm-anchor:--mm7}&:nth-of-type(8){--mm-anchor:--mm8}&:nth-of-type(9){--mm-anchor:--mm9}&:nth-of-type(10){--mm-anchor:--mm10}}.xl\:megamenu:not(:has([popovertarget]:hover,[popover]:popover-open)) .megamenu-active{background-color:#0000}.xl\:megamenu:has([popovertarget]:hover):not(:has([popover]:popover-open)) .megamenu-active,.xl\:megamenu:has([popover]:popover-open) .megamenu-active{inset:anchor(var(--mm-anchor) top) anchor(var(--mm-anchor) end) anchor(var(--mm-anchor) bottom) anchor(var(--mm-anchor) start)}.xl\:megamenu:has([popovertarget]:first-of-type:hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:first-of-type:popover-open){--mm-anchor:--mm1}.xl\:megamenu:has([popovertarget]:nth-of-type(2):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(2):popover-open){--mm-anchor:--mm2}.xl\:megamenu:has([popovertarget]:nth-of-type(3):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(3):popover-open){--mm-anchor:--mm3}.xl\:megamenu:has([popovertarget]:nth-of-type(4):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(4):popover-open){--mm-anchor:--mm4}.xl\:megamenu:has([popovertarget]:nth-of-type(5):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(5):popover-open){--mm-anchor:--mm5}.xl\:megamenu:has([popovertarget]:nth-of-type(6):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(6):popover-open){--mm-anchor:--mm6}.xl\:megamenu:has([popovertarget]:nth-of-type(7):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(7):popover-open){--mm-anchor:--mm7}.xl\:megamenu:has([popovertarget]:nth-of-type(8):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(8):popover-open){--mm-anchor:--mm8}.xl\:megamenu:has([popovertarget]:nth-of-type(9):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(9):popover-open){--mm-anchor:--mm9}.xl\:megamenu:has([popovertarget]:nth-of-type(10):hover):not(:has([popover]:popover-open)),.xl\:megamenu:has([popover]:nth-of-type(10):popover-open){--mm-anchor:--mm10}.xl\:megamenu [popovertarget]:has(+[popover]:popover-open):after{opacity:1;rotate:45deg}.xl\:megamenu [popover]{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-base-300);opacity:0;border-radius:var(--radius-box);background-color:var(--color-base-100);position-area:block-end span-inline-end;max-height:100dvh;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;margin-top:.25rem;translate:0 -.5rem;scale:.98;&:popover-open{opacity:1;@starting-style{opacity:0}}}.xl\:megamenu:has([popover]:popover-open) [popover]{translate:0;scale:1}.xl\:megamenu-active{pointer-events:none;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);border-radius:var(--radius-field);transition:inset .3s linear(0, .5 10%, .9 30%, 1.05 50%, 1.1 75%, 1), background-color .2s ease-out;position:absolute}}@layer daisyui.l1{.xl\:megamenu:popover-open{opacity:1;background-color:var(--color-base-100);width:100%;margin-top:var(--mm-mt,4rem);border-radius:0;flex-direction:column;align-items:flex-start;padding-top:1rem;position:fixed;overflow-y:scroll;& [popovertarget],&>[popover]:not(:first-of-type){display:var(--mm-display,none)}&::backdrop{background-color:var(--mm-backdrop,oklch(0% 0 0/.4))}position-area:block-end;top:0;max-block-size:80svh;transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;inset-inline-start:0;translate:0;scale:1;@starting-style{opacity:0}& [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}& [popover]{opacity:1;background-color:#0000;border:none;max-height:none;display:block;position:relative;overflow:visible}}}@layer daisyui.l1.l2{.xl\:megamenu-wide{anchor-name:--megamenu}.xl\:megamenu-wide:popover-open{inset-inline:0}.xl\:megamenu-wide [popover]{position-area:block-end;position-anchor:--megamenu;width:anchor-size(inline)}.xl\:megamenu-full{anchor-name:--megamenu}.xl\:megamenu-full:popover-open{inset-inline:0}.xl\:megamenu-full [popover]{position-area:block-end;position-anchor:--megamenu;border-inline-width:0;border-radius:0;width:100%}.xl\:megamenu-vertical{opacity:0;background-color:var(--color-base-100);position-area:block-end;width:100%;--mm-backdrop:transparent;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;border-radius:0;flex-direction:column;align-items:flex-start;position:relative;translate:0 -.5rem;scale:.98}.xl\:megamenu-vertical:not([popover]:popover-open){position:revert;display:none}.xl\:megamenu-vertical:popover-open{--mm-mt:0;--mm-display:block}.xl\:megamenu-vertical [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}.xl\:megamenu-vertical [popover]{opacity:1;background-color:#0000;border:none;display:block;position:relative;overflow:visible}.xl\:megamenu-xs{--fontsize:.6875rem;--mm-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.xl\:megamenu-sm{--fontsize:.75rem;--mm-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.xl\:megamenu-md{--fontsize:.875rem;--mm-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.xl\:megamenu-lg{--fontsize:1.125rem;--mm-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.xl\:megamenu-xl{--fontsize:1.375rem;--mm-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:megamenu{width:unset;border-radius:calc(var(--radius-field) + var(--border));--mm-anchor:--mm1;--size:calc(var(--size-field,.25rem) * 10);background-color:#0000;align-items:center;display:flex;position:relative;overflow:visible}.\32 xl\:megamenu [popovertarget]{isolation:isolate;cursor:pointer;padding-inline:var(--mm-p,1rem);height:var(--size);font-size:var(--fontsize,.875rem);border:var(--border) solid transparent;anchor-name:var(--mm-anchor);align-items:center;gap:.75rem;transition:background-color .2s ease-out,color .2s ease-out;display:flex;position:relative;&:focus-visible{outline:2px solid var(--color-base-content);outline-offset:2px}&:after{--tw-content:"";content:var(--tw-content);pointer-events:none;opacity:.25;width:.375rem;height:.375rem;transition:opacity .2s ease-out,rotate .2s ease-out;display:block;inset-inline-end:1.4rem;rotate:-135deg;box-shadow:inset 2px 2px}&:first-of-type{--mm-anchor:--mm1}&:nth-of-type(2){--mm-anchor:--mm2}&:nth-of-type(3){--mm-anchor:--mm3}&:nth-of-type(4){--mm-anchor:--mm4}&:nth-of-type(5){--mm-anchor:--mm5}&:nth-of-type(6){--mm-anchor:--mm6}&:nth-of-type(7){--mm-anchor:--mm7}&:nth-of-type(8){--mm-anchor:--mm8}&:nth-of-type(9){--mm-anchor:--mm9}&:nth-of-type(10){--mm-anchor:--mm10}}.\32 xl\:megamenu:not(:has([popovertarget]:hover,[popover]:popover-open)) .megamenu-active{background-color:#0000}.\32 xl\:megamenu:has([popovertarget]:hover):not(:has([popover]:popover-open)) .megamenu-active,.\32 xl\:megamenu:has([popover]:popover-open) .megamenu-active{inset:anchor(var(--mm-anchor) top) anchor(var(--mm-anchor) end) anchor(var(--mm-anchor) bottom) anchor(var(--mm-anchor) start)}.\32 xl\:megamenu:has([popovertarget]:first-of-type:hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:first-of-type:popover-open){--mm-anchor:--mm1}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(2):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(2):popover-open){--mm-anchor:--mm2}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(3):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(3):popover-open){--mm-anchor:--mm3}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(4):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(4):popover-open){--mm-anchor:--mm4}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(5):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(5):popover-open){--mm-anchor:--mm5}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(6):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(6):popover-open){--mm-anchor:--mm6}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(7):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(7):popover-open){--mm-anchor:--mm7}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(8):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(8):popover-open){--mm-anchor:--mm8}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(9):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(9):popover-open){--mm-anchor:--mm9}.\32 xl\:megamenu:has([popovertarget]:nth-of-type(10):hover):not(:has([popover]:popover-open)),.\32 xl\:megamenu:has([popover]:nth-of-type(10):popover-open){--mm-anchor:--mm10}.\32 xl\:megamenu [popovertarget]:has(+[popover]:popover-open):after{opacity:1;rotate:45deg}.\32 xl\:megamenu [popover]{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-base-300);opacity:0;border-radius:var(--radius-box);background-color:var(--color-base-100);position-area:block-end span-inline-end;max-height:100dvh;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;margin-top:.25rem;translate:0 -.5rem;scale:.98;&:popover-open{opacity:1;@starting-style{opacity:0}}}.\32 xl\:megamenu:has([popover]:popover-open) [popover]{translate:0;scale:1}.\32 xl\:megamenu-active{pointer-events:none;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);border-radius:var(--radius-field);transition:inset .3s linear(0, .5 10%, .9 30%, 1.05 50%, 1.1 75%, 1), background-color .2s ease-out;position:absolute}}@layer daisyui.l1{.\32 xl\:megamenu:popover-open{opacity:1;background-color:var(--color-base-100);width:100%;margin-top:var(--mm-mt,4rem);border-radius:0;flex-direction:column;align-items:flex-start;padding-top:1rem;position:fixed;overflow-y:scroll;& [popovertarget],&>[popover]:not(:first-of-type){display:var(--mm-display,none)}&::backdrop{background-color:var(--mm-backdrop,oklch(0% 0 0/.4))}position-area:block-end;top:0;max-block-size:80svh;transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;inset-inline-start:0;translate:0;scale:1;@starting-style{opacity:0}& [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}& [popover]{opacity:1;background-color:#0000;border:none;max-height:none;display:block;position:relative;overflow:visible}}}@layer daisyui.l1.l2{.\32 xl\:megamenu-wide{anchor-name:--megamenu}.\32 xl\:megamenu-wide:popover-open{inset-inline:0}.\32 xl\:megamenu-wide [popover]{position-area:block-end;position-anchor:--megamenu;width:anchor-size(inline)}.\32 xl\:megamenu-full{anchor-name:--megamenu}.\32 xl\:megamenu-full:popover-open{inset-inline:0}.\32 xl\:megamenu-full [popover]{position-area:block-end;position-anchor:--megamenu;border-inline-width:0;border-radius:0;width:100%}.\32 xl\:megamenu-vertical{opacity:0;background-color:var(--color-base-100);position-area:block-end;width:100%;--mm-backdrop:transparent;max-block-size:calc(100% - .25rem);transition:opacity .2s ease-out, translate .2s ease-out, scale .2s ease-out, display .2s ease-out allow-discrete, overlay .2s ease-out allow-discrete;border-inline-width:0;border-radius:0;flex-direction:column;align-items:flex-start;position:relative;translate:0 -.5rem;scale:.98}.\32 xl\:megamenu-vertical:not([popover]:popover-open){position:revert;display:none}.\32 xl\:megamenu-vertical:popover-open{--mm-mt:0;--mm-display:block}.\32 xl\:megamenu-vertical [popovertarget]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 40%, transparent);font-size:.875rem;font-weight:600;&:after{content:none}}.\32 xl\:megamenu-vertical [popover]{opacity:1;background-color:#0000;border:none;display:block;position:relative;overflow:visible}.\32 xl\:megamenu-xs{--fontsize:.6875rem;--mm-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.\32 xl\:megamenu-sm{--fontsize:.75rem;--mm-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.\32 xl\:megamenu-md{--fontsize:.875rem;--mm-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.\32 xl\:megamenu-lg{--fontsize:1.125rem;--mm-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.\32 xl\:megamenu-xl{--fontsize:1.375rem;--mm-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}}}.text-rotate{height:1lh;@layer daisyui.l1.l2.l3{vertical-align:bottom;white-space:nowrap;--duration:var(--tw-duration);transition-property:none;display:inline-block;overflow:hidden;&>*{height:calc(var(--items,1) * 100%);justify-items:start;display:grid;&:has(>:nth-child(2)){--items:2;@media (prefers-reduced-motion:no-preference){animation:rotator var(--duration,10s) linear(0 0% 49%, .5 50% 99%, 1 100% 100%) infinite}@media (prefers-reduced-motion:reduce){animation:rotator var(--duration,10s) steps(var(--items), jump-end) infinite}}&:has(>:nth-child(3)){--items:3;@media (prefers-reduced-motion:no-preference){animation:rotator var(--duration,10s) linear(0 0% 32%, .333333 33% 65%, .666666 66% 99%, 1 100% 100%) infinite}}&:has(>:nth-child(4)){--items:4;@media (prefers-reduced-motion:no-preference){animation:rotator var(--duration,10s) linear(0 0% 24%, .25 25% 49%, .5 50% 74%, .75 75% 99%, 1 100% 100%) infinite}}&:has(>:nth-child(5)){--items:5;@media (prefers-reduced-motion:no-preference){animation:rotator var(--duration,10s) linear(0 0% 19%, .2 20% 39%, .4 40% 59%, .6 60% 79%, .8 80% 99%, 1 100% 100%) infinite}}&:has(>:nth-child(6)){--items:6;@media (prefers-reduced-motion:no-preference){animation:rotator var(--duration,10s) linear(0 0% 15%, .16666 16% 32%, .333333 33% 49%, .5 50% 65%, .666666 66% 82%, .833333 83% 99%, 1 100% 100%) infinite}}&>*{clip-path:inset(.5px 0);align-content:baseline;&:first-child{translate:var(--first-item-position)}}}&:hover{&>*{animation-play-state:paused}}}}@keyframes rotator{89.9999%,to{--first-item-position:0 0%}90%,99.9999%{--first-item-position:0 calc(var(--items) * 100%)}to{translate:0 -100%}}.countdown.countdown{line-height:1em}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:indicator{width:max-content;display:inline-flex;position:relative}.sm\:indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}}@layer daisyui.l1.l2{.sm\:indicator-start{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-start{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}.sm\:indicator-center{--indicator-s:50%;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-center{--indicator-e:50%;--indicator-x:50%}.sm\:indicator-end{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}[dir=rtl] .indicator-end{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}.sm\:indicator-bottom{--indicator-t:auto;--indicator-b:0;--indicator-y:50%}.sm\:indicator-middle{--indicator-t:50%;--indicator-b:auto;--indicator-y:-50%}.sm\:indicator-top{--indicator-t:0;--indicator-b:auto;--indicator-y:-50%}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:indicator{width:max-content;display:inline-flex;position:relative}.md\:indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}}@layer daisyui.l1.l2{.md\:indicator-start{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-start{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}.md\:indicator-center{--indicator-s:50%;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-center{--indicator-e:50%;--indicator-x:50%}.md\:indicator-end{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}[dir=rtl] .indicator-end{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}.md\:indicator-bottom{--indicator-t:auto;--indicator-b:0;--indicator-y:50%}.md\:indicator-middle{--indicator-t:50%;--indicator-b:auto;--indicator-y:-50%}.md\:indicator-top{--indicator-t:0;--indicator-b:auto;--indicator-y:-50%}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:indicator{width:max-content;display:inline-flex;position:relative}.lg\:indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}}@layer daisyui.l1.l2{.lg\:indicator-start{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-start{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}.lg\:indicator-center{--indicator-s:50%;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-center{--indicator-e:50%;--indicator-x:50%}.lg\:indicator-end{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}[dir=rtl] .indicator-end{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}.lg\:indicator-bottom{--indicator-t:auto;--indicator-b:0;--indicator-y:50%}.lg\:indicator-middle{--indicator-t:50%;--indicator-b:auto;--indicator-y:-50%}.lg\:indicator-top{--indicator-t:0;--indicator-b:auto;--indicator-y:-50%}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:indicator{width:max-content;display:inline-flex;position:relative}.xl\:indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}}@layer daisyui.l1.l2{.xl\:indicator-start{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-start{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}.xl\:indicator-center{--indicator-s:50%;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-center{--indicator-e:50%;--indicator-x:50%}.xl\:indicator-end{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}[dir=rtl] .indicator-end{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}.xl\:indicator-bottom{--indicator-t:auto;--indicator-b:0;--indicator-y:50%}.xl\:indicator-middle{--indicator-t:50%;--indicator-b:auto;--indicator-y:-50%}.xl\:indicator-top{--indicator-t:0;--indicator-b:auto;--indicator-y:-50%}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:indicator{width:max-content;display:inline-flex;position:relative}.\32 xl\:indicator :where(.indicator-item){z-index:1;white-space:nowrap;top:var(--indicator-t,0);bottom:var(--indicator-b,auto);left:var(--indicator-s,auto);right:var(--indicator-e,0);translate:var(--indicator-x,50%) var(--indicator-y,-50%);position:absolute}}@layer daisyui.l1.l2{.\32 xl\:indicator-start{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-start{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}.\32 xl\:indicator-center{--indicator-s:50%;--indicator-e:auto;--indicator-x:-50%}[dir=rtl] .indicator-center{--indicator-e:50%;--indicator-x:50%}.\32 xl\:indicator-end{--indicator-s:auto;--indicator-e:0;--indicator-x:50%}[dir=rtl] .indicator-end{--indicator-s:0;--indicator-e:auto;--indicator-x:-50%}.\32 xl\:indicator-bottom{--indicator-t:auto;--indicator-b:0;--indicator-y:50%}.\32 xl\:indicator-middle{--indicator-t:50%;--indicator-b:auto;--indicator-y:-50%}.\32 xl\:indicator-top{--indicator-t:0;--indicator-b:auto;--indicator-y:-50%}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.sm\:fieldset-legend{color:var(--color-base-content);margin-bottom:-.25rem;justify-content:space-between;align-items:center;gap:.5rem;margin-inline-end:auto;padding-block:.5rem;font-weight:600;display:flex}.sm\:fieldset-label{color:color-mix(in oklab, var(--color-base-content) 60%, transparent);align-items:center;gap:.375rem;display:flex}.sm\:fieldset-label:has(input){cursor:pointer}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.md\:fieldset-legend{color:var(--color-base-content);margin-bottom:-.25rem;justify-content:space-between;align-items:center;gap:.5rem;margin-inline-end:auto;padding-block:.5rem;font-weight:600;display:flex}.md\:fieldset-label{color:color-mix(in oklab, var(--color-base-content) 60%, transparent);align-items:center;gap:.375rem;display:flex}.md\:fieldset-label:has(input){cursor:pointer}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.lg\:fieldset-legend{color:var(--color-base-content);margin-bottom:-.25rem;justify-content:space-between;align-items:center;gap:.5rem;margin-inline-end:auto;padding-block:.5rem;font-weight:600;display:flex}.lg\:fieldset-label{color:color-mix(in oklab, var(--color-base-content) 60%, transparent);align-items:center;gap:.375rem;display:flex}.lg\:fieldset-label:has(input){cursor:pointer}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.xl\:fieldset-legend{color:var(--color-base-content);margin-bottom:-.25rem;justify-content:space-between;align-items:center;gap:.5rem;margin-inline-end:auto;padding-block:.5rem;font-weight:600;display:flex}.xl\:fieldset-label{color:color-mix(in oklab, var(--color-base-content) 60%, transparent);align-items:center;gap:.375rem;display:flex}.xl\:fieldset-label:has(input){cursor:pointer}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.\32 xl\:fieldset-legend{color:var(--color-base-content);margin-bottom:-.25rem;justify-content:space-between;align-items:center;gap:.5rem;margin-inline-end:auto;padding-block:.5rem;font-weight:600;display:flex}.\32 xl\:fieldset-label{color:color-mix(in oklab, var(--color-base-content) 60%, transparent);align-items:center;gap:.375rem;display:flex}.\32 xl\:fieldset-label:has(input){cursor:pointer}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);flex-direction:row;align-self:stretch;align-items:center;display:flex}.sm\:divider:before,.sm\:divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.sm\:divider:before,.sm\:divider:after{border:.5px solid}}.sm\:divider:not(:empty){gap:1rem}}@layer daisyui.l1.l2{.sm\:divider-horizontal{--divider-m:0 1rem}.sm\:divider-horizontal.divider{flex-direction:column;width:1rem;height:auto;&:before,&:after{width:.125rem;height:100%}}.sm\:divider-vertical{--divider-m:1rem 0}.sm\:divider-vertical.divider{flex-direction:row;width:auto;height:1rem;&:before,&:after{width:100%;height:.125rem}}.sm\:divider-neutral:before,.sm\:divider-neutral:after{background-color:var(--color-neutral)}.sm\:divider-primary:before,.sm\:divider-primary:after{background-color:var(--color-primary)}.sm\:divider-secondary:before,.sm\:divider-secondary:after{background-color:var(--color-secondary)}.sm\:divider-accent:before,.sm\:divider-accent:after{background-color:var(--color-accent)}.sm\:divider-success:before,.sm\:divider-success:after{background-color:var(--color-success)}.sm\:divider-warning:before,.sm\:divider-warning:after{background-color:var(--color-warning)}.sm\:divider-info:before,.sm\:divider-info:after{background-color:var(--color-info)}.sm\:divider-error:before,.sm\:divider-error:after{background-color:var(--color-error)}.sm\:divider-start:before,.sm\:divider-end:after{display:none}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);flex-direction:row;align-self:stretch;align-items:center;display:flex}.md\:divider:before,.md\:divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.md\:divider:before,.md\:divider:after{border:.5px solid}}.md\:divider:not(:empty){gap:1rem}}@layer daisyui.l1.l2{.md\:divider-horizontal{--divider-m:0 1rem}.md\:divider-horizontal.divider{flex-direction:column;width:1rem;height:auto;&:before,&:after{width:.125rem;height:100%}}.md\:divider-vertical{--divider-m:1rem 0}.md\:divider-vertical.divider{flex-direction:row;width:auto;height:1rem;&:before,&:after{width:100%;height:.125rem}}.md\:divider-neutral:before,.md\:divider-neutral:after{background-color:var(--color-neutral)}.md\:divider-primary:before,.md\:divider-primary:after{background-color:var(--color-primary)}.md\:divider-secondary:before,.md\:divider-secondary:after{background-color:var(--color-secondary)}.md\:divider-accent:before,.md\:divider-accent:after{background-color:var(--color-accent)}.md\:divider-success:before,.md\:divider-success:after{background-color:var(--color-success)}.md\:divider-warning:before,.md\:divider-warning:after{background-color:var(--color-warning)}.md\:divider-info:before,.md\:divider-info:after{background-color:var(--color-info)}.md\:divider-error:before,.md\:divider-error:after{background-color:var(--color-error)}.md\:divider-start:before,.md\:divider-end:after{display:none}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);flex-direction:row;align-self:stretch;align-items:center;display:flex}.lg\:divider:before,.lg\:divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.lg\:divider:before,.lg\:divider:after{border:.5px solid}}.lg\:divider:not(:empty){gap:1rem}}@layer daisyui.l1.l2{.lg\:divider-horizontal{--divider-m:0 1rem}.lg\:divider-horizontal.divider{flex-direction:column;width:1rem;height:auto;&:before,&:after{width:.125rem;height:100%}}.lg\:divider-vertical{--divider-m:1rem 0}.lg\:divider-vertical.divider{flex-direction:row;width:auto;height:1rem;&:before,&:after{width:100%;height:.125rem}}.lg\:divider-neutral:before,.lg\:divider-neutral:after{background-color:var(--color-neutral)}.lg\:divider-primary:before,.lg\:divider-primary:after{background-color:var(--color-primary)}.lg\:divider-secondary:before,.lg\:divider-secondary:after{background-color:var(--color-secondary)}.lg\:divider-accent:before,.lg\:divider-accent:after{background-color:var(--color-accent)}.lg\:divider-success:before,.lg\:divider-success:after{background-color:var(--color-success)}.lg\:divider-warning:before,.lg\:divider-warning:after{background-color:var(--color-warning)}.lg\:divider-info:before,.lg\:divider-info:after{background-color:var(--color-info)}.lg\:divider-error:before,.lg\:divider-error:after{background-color:var(--color-error)}.lg\:divider-start:before,.lg\:divider-end:after{display:none}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);flex-direction:row;align-self:stretch;align-items:center;display:flex}.xl\:divider:before,.xl\:divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.xl\:divider:before,.xl\:divider:after{border:.5px solid}}.xl\:divider:not(:empty){gap:1rem}}@layer daisyui.l1.l2{.xl\:divider-horizontal{--divider-m:0 1rem}.xl\:divider-horizontal.divider{flex-direction:column;width:1rem;height:auto;&:before,&:after{width:.125rem;height:100%}}.xl\:divider-vertical{--divider-m:1rem 0}.xl\:divider-vertical.divider{flex-direction:row;width:auto;height:1rem;&:before,&:after{width:100%;height:.125rem}}.xl\:divider-neutral:before,.xl\:divider-neutral:after{background-color:var(--color-neutral)}.xl\:divider-primary:before,.xl\:divider-primary:after{background-color:var(--color-primary)}.xl\:divider-secondary:before,.xl\:divider-secondary:after{background-color:var(--color-secondary)}.xl\:divider-accent:before,.xl\:divider-accent:after{background-color:var(--color-accent)}.xl\:divider-success:before,.xl\:divider-success:after{background-color:var(--color-success)}.xl\:divider-warning:before,.xl\:divider-warning:after{background-color:var(--color-warning)}.xl\:divider-info:before,.xl\:divider-info:after{background-color:var(--color-info)}.xl\:divider-error:before,.xl\:divider-error:after{background-color:var(--color-error)}.xl\:divider-start:before,.xl\:divider-end:after{display:none}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);flex-direction:row;align-self:stretch;align-items:center;display:flex}.\32 xl\:divider:before,.\32 xl\:divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.\32 xl\:divider:before,.\32 xl\:divider:after{border:.5px solid}}.\32 xl\:divider:not(:empty){gap:1rem}}@layer daisyui.l1.l2{.\32 xl\:divider-horizontal{--divider-m:0 1rem}.\32 xl\:divider-horizontal.divider{flex-direction:column;width:1rem;height:auto;&:before,&:after{width:.125rem;height:100%}}.\32 xl\:divider-vertical{--divider-m:1rem 0}.\32 xl\:divider-vertical.divider{flex-direction:row;width:auto;height:1rem;&:before,&:after{width:100%;height:.125rem}}.\32 xl\:divider-neutral:before,.\32 xl\:divider-neutral:after{background-color:var(--color-neutral)}.\32 xl\:divider-primary:before,.\32 xl\:divider-primary:after{background-color:var(--color-primary)}.\32 xl\:divider-secondary:before,.\32 xl\:divider-secondary:after{background-color:var(--color-secondary)}.\32 xl\:divider-accent:before,.\32 xl\:divider-accent:after{background-color:var(--color-accent)}.\32 xl\:divider-success:before,.\32 xl\:divider-success:after{background-color:var(--color-success)}.\32 xl\:divider-warning:before,.\32 xl\:divider-warning:after{background-color:var(--color-warning)}.\32 xl\:divider-info:before,.\32 xl\:divider-info:after{background-color:var(--color-info)}.\32 xl\:divider-error:before,.\32 xl\:divider-error:after{background-color:var(--color-error)}.\32 xl\:divider-start:before,.\32 xl\:divider-end:after{display:none}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000);--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;transition:color .3s,grid-template-columns .2s;display:inline-grid;position:relative}.sm\:toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s;&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:nth-child(2){color:var(--color-base-100);rotate:0deg}&:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}}.sm\:toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.sm\:toggle:has(:checked)>:nth-child(3){opacity:1;rotate:0deg}.sm\:toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);background-color:currentColor;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@media (forced-colors:active){.sm\:toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.sm\:toggle:before{outline-offset:-1rem;outline:.25rem solid}}.sm\:toggle:focus-visible,.sm\:toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.sm\:toggle:checked,.sm\:toggle[aria-checked=true],.sm\:toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr;&:before{background-color:currentColor}@starting-style{&:before{opacity:0}}}.sm\:toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.sm\:toggle:disabled{cursor:not-allowed;opacity:.3;&:before{border:var(--border) solid currentColor;background-color:#0000}}}@layer daisyui.l1.l2{.sm\:toggle-primary:checked,.sm\:toggle-primary[aria-checked=true]{--input-color:var(--color-primary)}.sm\:toggle-secondary:checked,.sm\:toggle-secondary[aria-checked=true]{--input-color:var(--color-secondary)}.sm\:toggle-accent:checked,.sm\:toggle-accent[aria-checked=true]{--input-color:var(--color-accent)}.sm\:toggle-neutral:checked,.sm\:toggle-neutral[aria-checked=true]{--input-color:var(--color-neutral)}.sm\:toggle-success:checked,.sm\:toggle-success[aria-checked=true]{--input-color:var(--color-success)}.sm\:toggle-warning:checked,.sm\:toggle-warning[aria-checked=true]{--input-color:var(--color-warning)}.sm\:toggle-info:checked,.sm\:toggle-info[aria-checked=true]{--input-color:var(--color-info)}.sm\:toggle-error:checked,.sm\:toggle-error[aria-checked=true]{--input-color:var(--color-error)}.sm\:toggle-xs[type=checkbox],.sm\:toggle-xs:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 4)}.sm\:toggle-sm[type=checkbox],.sm\:toggle-sm:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 5)}.sm\:toggle-md[type=checkbox],.sm\:toggle-md:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 6)}.sm\:toggle-lg[type=checkbox],.sm\:toggle-lg:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 7)}.sm\:toggle-xl[type=checkbox],.sm\:toggle-xl:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000);--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;transition:color .3s,grid-template-columns .2s;display:inline-grid;position:relative}.md\:toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s;&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:nth-child(2){color:var(--color-base-100);rotate:0deg}&:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}}.md\:toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.md\:toggle:has(:checked)>:nth-child(3){opacity:1;rotate:0deg}.md\:toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);background-color:currentColor;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@media (forced-colors:active){.md\:toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.md\:toggle:before{outline-offset:-1rem;outline:.25rem solid}}.md\:toggle:focus-visible,.md\:toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.md\:toggle:checked,.md\:toggle[aria-checked=true],.md\:toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr;&:before{background-color:currentColor}@starting-style{&:before{opacity:0}}}.md\:toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.md\:toggle:disabled{cursor:not-allowed;opacity:.3;&:before{border:var(--border) solid currentColor;background-color:#0000}}}@layer daisyui.l1.l2{.md\:toggle-primary:checked,.md\:toggle-primary[aria-checked=true]{--input-color:var(--color-primary)}.md\:toggle-secondary:checked,.md\:toggle-secondary[aria-checked=true]{--input-color:var(--color-secondary)}.md\:toggle-accent:checked,.md\:toggle-accent[aria-checked=true]{--input-color:var(--color-accent)}.md\:toggle-neutral:checked,.md\:toggle-neutral[aria-checked=true]{--input-color:var(--color-neutral)}.md\:toggle-success:checked,.md\:toggle-success[aria-checked=true]{--input-color:var(--color-success)}.md\:toggle-warning:checked,.md\:toggle-warning[aria-checked=true]{--input-color:var(--color-warning)}.md\:toggle-info:checked,.md\:toggle-info[aria-checked=true]{--input-color:var(--color-info)}.md\:toggle-error:checked,.md\:toggle-error[aria-checked=true]{--input-color:var(--color-error)}.md\:toggle-xs[type=checkbox],.md\:toggle-xs:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 4)}.md\:toggle-sm[type=checkbox],.md\:toggle-sm:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 5)}.md\:toggle-md[type=checkbox],.md\:toggle-md:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 6)}.md\:toggle-lg[type=checkbox],.md\:toggle-lg:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 7)}.md\:toggle-xl[type=checkbox],.md\:toggle-xl:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000);--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;transition:color .3s,grid-template-columns .2s;display:inline-grid;position:relative}.lg\:toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s;&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:nth-child(2){color:var(--color-base-100);rotate:0deg}&:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}}.lg\:toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.lg\:toggle:has(:checked)>:nth-child(3){opacity:1;rotate:0deg}.lg\:toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);background-color:currentColor;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@media (forced-colors:active){.lg\:toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.lg\:toggle:before{outline-offset:-1rem;outline:.25rem solid}}.lg\:toggle:focus-visible,.lg\:toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.lg\:toggle:checked,.lg\:toggle[aria-checked=true],.lg\:toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr;&:before{background-color:currentColor}@starting-style{&:before{opacity:0}}}.lg\:toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.lg\:toggle:disabled{cursor:not-allowed;opacity:.3;&:before{border:var(--border) solid currentColor;background-color:#0000}}}@layer daisyui.l1.l2{.lg\:toggle-primary:checked,.lg\:toggle-primary[aria-checked=true]{--input-color:var(--color-primary)}.lg\:toggle-secondary:checked,.lg\:toggle-secondary[aria-checked=true]{--input-color:var(--color-secondary)}.lg\:toggle-accent:checked,.lg\:toggle-accent[aria-checked=true]{--input-color:var(--color-accent)}.lg\:toggle-neutral:checked,.lg\:toggle-neutral[aria-checked=true]{--input-color:var(--color-neutral)}.lg\:toggle-success:checked,.lg\:toggle-success[aria-checked=true]{--input-color:var(--color-success)}.lg\:toggle-warning:checked,.lg\:toggle-warning[aria-checked=true]{--input-color:var(--color-warning)}.lg\:toggle-info:checked,.lg\:toggle-info[aria-checked=true]{--input-color:var(--color-info)}.lg\:toggle-error:checked,.lg\:toggle-error[aria-checked=true]{--input-color:var(--color-error)}.lg\:toggle-xs[type=checkbox],.lg\:toggle-xs:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 4)}.lg\:toggle-sm[type=checkbox],.lg\:toggle-sm:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 5)}.lg\:toggle-md[type=checkbox],.lg\:toggle-md:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 6)}.lg\:toggle-lg[type=checkbox],.lg\:toggle-lg:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 7)}.lg\:toggle-xl[type=checkbox],.lg\:toggle-xl:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000);--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;transition:color .3s,grid-template-columns .2s;display:inline-grid;position:relative}.xl\:toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s;&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:nth-child(2){color:var(--color-base-100);rotate:0deg}&:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}}.xl\:toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.xl\:toggle:has(:checked)>:nth-child(3){opacity:1;rotate:0deg}.xl\:toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);background-color:currentColor;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@media (forced-colors:active){.xl\:toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.xl\:toggle:before{outline-offset:-1rem;outline:.25rem solid}}.xl\:toggle:focus-visible,.xl\:toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.xl\:toggle:checked,.xl\:toggle[aria-checked=true],.xl\:toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr;&:before{background-color:currentColor}@starting-style{&:before{opacity:0}}}.xl\:toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.xl\:toggle:disabled{cursor:not-allowed;opacity:.3;&:before{border:var(--border) solid currentColor;background-color:#0000}}}@layer daisyui.l1.l2{.xl\:toggle-primary:checked,.xl\:toggle-primary[aria-checked=true]{--input-color:var(--color-primary)}.xl\:toggle-secondary:checked,.xl\:toggle-secondary[aria-checked=true]{--input-color:var(--color-secondary)}.xl\:toggle-accent:checked,.xl\:toggle-accent[aria-checked=true]{--input-color:var(--color-accent)}.xl\:toggle-neutral:checked,.xl\:toggle-neutral[aria-checked=true]{--input-color:var(--color-neutral)}.xl\:toggle-success:checked,.xl\:toggle-success[aria-checked=true]{--input-color:var(--color-success)}.xl\:toggle-warning:checked,.xl\:toggle-warning[aria-checked=true]{--input-color:var(--color-warning)}.xl\:toggle-info:checked,.xl\:toggle-info[aria-checked=true]{--input-color:var(--color-info)}.xl\:toggle-error:checked,.xl\:toggle-error[aria-checked=true]{--input-color:var(--color-error)}.xl\:toggle-xs[type=checkbox],.xl\:toggle-xs:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 4)}.xl\:toggle-sm[type=checkbox],.xl\:toggle-sm:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 5)}.xl\:toggle-md[type=checkbox],.xl\:toggle-md:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 6)}.xl\:toggle-lg[type=checkbox],.xl\:toggle-lg:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 7)}.xl\:toggle-xl[type=checkbox],.xl\:toggle-xl:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset;--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000);--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;transition:color .3s,grid-template-columns .2s;display:inline-grid;position:relative}.\32 xl\:toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s;&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:nth-child(2){color:var(--color-base-100);rotate:0deg}&:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}}.\32 xl\:toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.\32 xl\:toggle:has(:checked)>:nth-child(3){opacity:1;rotate:0deg}.\32 xl\:toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000);background-color:currentColor;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@media (forced-colors:active){.\32 xl\:toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.\32 xl\:toggle:before{outline-offset:-1rem;outline:.25rem solid}}.\32 xl\:toggle:focus-visible,.\32 xl\:toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.\32 xl\:toggle:checked,.\32 xl\:toggle[aria-checked=true],.\32 xl\:toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr;&:before{background-color:currentColor}@starting-style{&:before{opacity:0}}}.\32 xl\:toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.\32 xl\:toggle:disabled{cursor:not-allowed;opacity:.3;&:before{border:var(--border) solid currentColor;background-color:#0000}}}@layer daisyui.l1.l2{.\32 xl\:toggle-primary:checked,.\32 xl\:toggle-primary[aria-checked=true]{--input-color:var(--color-primary)}.\32 xl\:toggle-secondary:checked,.\32 xl\:toggle-secondary[aria-checked=true]{--input-color:var(--color-secondary)}.\32 xl\:toggle-accent:checked,.\32 xl\:toggle-accent[aria-checked=true]{--input-color:var(--color-accent)}.\32 xl\:toggle-neutral:checked,.\32 xl\:toggle-neutral[aria-checked=true]{--input-color:var(--color-neutral)}.\32 xl\:toggle-success:checked,.\32 xl\:toggle-success[aria-checked=true]{--input-color:var(--color-success)}.\32 xl\:toggle-warning:checked,.\32 xl\:toggle-warning[aria-checked=true]{--input-color:var(--color-warning)}.\32 xl\:toggle-info:checked,.\32 xl\:toggle-info[aria-checked=true]{--input-color:var(--color-info)}.\32 xl\:toggle-error:checked,.\32 xl\:toggle-error[aria-checked=true]{--input-color:var(--color-error)}.\32 xl\:toggle-xs[type=checkbox],.\32 xl\:toggle-xs:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 4)}.\32 xl\:toggle-sm[type=checkbox],.\32 xl\:toggle-sm:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 5)}.\32 xl\:toggle-md[type=checkbox],.\32 xl\:toggle-md:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 6)}.\32 xl\:toggle-lg[type=checkbox],.\32 xl\:toggle-lg:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 7)}.\32 xl\:toggle-xl[type=checkbox],.\32 xl\:toggle-xl:has([type=checkbox]){--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:avatar-group{display:flex;overflow:hidden}.sm\:avatar-group .avatar{border:4px solid var(--color-base-100);border-radius:3.40282e38px;overflow:hidden}.sm\:avatar{vertical-align:middle;align-self:center;display:inline-flex;position:relative}.sm\:avatar>div{aspect-ratio:1;display:block;overflow:hidden}.sm\:avatar img{object-fit:cover;width:100%;height:100%}}@layer daisyui.l1.l2{.sm\:avatar-placeholder>div{justify-content:center;align-items:center;display:flex}.sm\:avatar-online:before{content:"";z-index:1;background-color:var(--color-success);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}.sm\:avatar-offline:before{content:"";z-index:1;background-color:var(--color-base-300);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:avatar-group{display:flex;overflow:hidden}.md\:avatar-group .avatar{border:4px solid var(--color-base-100);border-radius:3.40282e38px;overflow:hidden}.md\:avatar{vertical-align:middle;align-self:center;display:inline-flex;position:relative}.md\:avatar>div{aspect-ratio:1;display:block;overflow:hidden}.md\:avatar img{object-fit:cover;width:100%;height:100%}}@layer daisyui.l1.l2{.md\:avatar-placeholder>div{justify-content:center;align-items:center;display:flex}.md\:avatar-online:before{content:"";z-index:1;background-color:var(--color-success);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}.md\:avatar-offline:before{content:"";z-index:1;background-color:var(--color-base-300);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:avatar-group{display:flex;overflow:hidden}.lg\:avatar-group .avatar{border:4px solid var(--color-base-100);border-radius:3.40282e38px;overflow:hidden}.lg\:avatar{vertical-align:middle;align-self:center;display:inline-flex;position:relative}.lg\:avatar>div{aspect-ratio:1;display:block;overflow:hidden}.lg\:avatar img{object-fit:cover;width:100%;height:100%}}@layer daisyui.l1.l2{.lg\:avatar-placeholder>div{justify-content:center;align-items:center;display:flex}.lg\:avatar-online:before{content:"";z-index:1;background-color:var(--color-success);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}.lg\:avatar-offline:before{content:"";z-index:1;background-color:var(--color-base-300);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:avatar-group{display:flex;overflow:hidden}.xl\:avatar-group .avatar{border:4px solid var(--color-base-100);border-radius:3.40282e38px;overflow:hidden}.xl\:avatar{vertical-align:middle;align-self:center;display:inline-flex;position:relative}.xl\:avatar>div{aspect-ratio:1;display:block;overflow:hidden}.xl\:avatar img{object-fit:cover;width:100%;height:100%}}@layer daisyui.l1.l2{.xl\:avatar-placeholder>div{justify-content:center;align-items:center;display:flex}.xl\:avatar-online:before{content:"";z-index:1;background-color:var(--color-success);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}.xl\:avatar-offline:before{content:"";z-index:1;background-color:var(--color-base-300);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:avatar-group{display:flex;overflow:hidden}.\32 xl\:avatar-group .avatar{border:4px solid var(--color-base-100);border-radius:3.40282e38px;overflow:hidden}.\32 xl\:avatar{vertical-align:middle;align-self:center;display:inline-flex;position:relative}.\32 xl\:avatar>div{aspect-ratio:1;display:block;overflow:hidden}.\32 xl\:avatar img{object-fit:cover;width:100%;height:100%}}@layer daisyui.l1.l2{.\32 xl\:avatar-placeholder>div{justify-content:center;align-items:center;display:flex}.\32 xl\:avatar-online:before{content:"";z-index:1;background-color:var(--color-success);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}.\32 xl\:avatar-offline:before{content:"";z-index:1;background-color:var(--color-base-300);outline:2px solid var(--color-base-100);top:7%;border-radius:3.40282e38px;width:15%;height:15%;display:block;position:absolute;inset-inline-end:7%}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:breadcrumbs{max-width:100%;margin-inline-start:-.25rem;padding-block:.5rem;overflow-x:auto}.sm\:breadcrumbs>menu,.sm\:breadcrumbs>ul,.sm\:breadcrumbs>ol{white-space:nowrap;align-items:center;min-height:min-content;padding-inline-start:.25rem;display:flex;&>li{align-items:center;display:flex;&>*{cursor:pointer;align-items:center;gap:.5rem;display:flex;&:hover{@media (hover:hover){text-decoration-line:underline}}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible{outline-offset:2px;outline:2px solid}}&+:before{content:"";opacity:.4;background-color:#0000;border-top:1px solid;border-right:1px solid;width:.375rem;height:.375rem;margin-inline:.5rem .75rem;display:block;rotate:45deg}[dir=rtl] &+:before{rotate:-135deg}}}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:breadcrumbs{max-width:100%;margin-inline-start:-.25rem;padding-block:.5rem;overflow-x:auto}.md\:breadcrumbs>menu,.md\:breadcrumbs>ul,.md\:breadcrumbs>ol{white-space:nowrap;align-items:center;min-height:min-content;padding-inline-start:.25rem;display:flex;&>li{align-items:center;display:flex;&>*{cursor:pointer;align-items:center;gap:.5rem;display:flex;&:hover{@media (hover:hover){text-decoration-line:underline}}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible{outline-offset:2px;outline:2px solid}}&+:before{content:"";opacity:.4;background-color:#0000;border-top:1px solid;border-right:1px solid;width:.375rem;height:.375rem;margin-inline:.5rem .75rem;display:block;rotate:45deg}[dir=rtl] &+:before{rotate:-135deg}}}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:breadcrumbs{max-width:100%;margin-inline-start:-.25rem;padding-block:.5rem;overflow-x:auto}.lg\:breadcrumbs>menu,.lg\:breadcrumbs>ul,.lg\:breadcrumbs>ol{white-space:nowrap;align-items:center;min-height:min-content;padding-inline-start:.25rem;display:flex;&>li{align-items:center;display:flex;&>*{cursor:pointer;align-items:center;gap:.5rem;display:flex;&:hover{@media (hover:hover){text-decoration-line:underline}}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible{outline-offset:2px;outline:2px solid}}&+:before{content:"";opacity:.4;background-color:#0000;border-top:1px solid;border-right:1px solid;width:.375rem;height:.375rem;margin-inline:.5rem .75rem;display:block;rotate:45deg}[dir=rtl] &+:before{rotate:-135deg}}}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:breadcrumbs{max-width:100%;margin-inline-start:-.25rem;padding-block:.5rem;overflow-x:auto}.xl\:breadcrumbs>menu,.xl\:breadcrumbs>ul,.xl\:breadcrumbs>ol{white-space:nowrap;align-items:center;min-height:min-content;padding-inline-start:.25rem;display:flex;&>li{align-items:center;display:flex;&>*{cursor:pointer;align-items:center;gap:.5rem;display:flex;&:hover{@media (hover:hover){text-decoration-line:underline}}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible{outline-offset:2px;outline:2px solid}}&+:before{content:"";opacity:.4;background-color:#0000;border-top:1px solid;border-right:1px solid;width:.375rem;height:.375rem;margin-inline:.5rem .75rem;display:block;rotate:45deg}[dir=rtl] &+:before{rotate:-135deg}}}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:breadcrumbs{max-width:100%;margin-inline-start:-.25rem;padding-block:.5rem;overflow-x:auto}.\32 xl\:breadcrumbs>menu,.\32 xl\:breadcrumbs>ul,.\32 xl\:breadcrumbs>ol{white-space:nowrap;align-items:center;min-height:min-content;padding-inline-start:.25rem;display:flex;&>li{align-items:center;display:flex;&>*{cursor:pointer;align-items:center;gap:.5rem;display:flex;&:hover{@media (hover:hover){text-decoration-line:underline}}&:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}&:focus-visible{outline-offset:2px;outline:2px solid}}&+:before{content:"";opacity:.4;background-color:#0000;border-top:1px solid;border-right:1px solid;width:.375rem;height:.375rem;margin-inline:.5rem .75rem;display:block;rotate:45deg}[dir=rtl] &+:before{rotate:-135deg}}}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:hover-gallery{--items:1;grid-template-columns:repeat(var(--items), 1fr);gap:1px;width:100%;overflow:hidden}.hover-gallery,.sm\:hover-gallery:is(figure){display:inline-grid}.sm\:hover-gallery:has(>:nth-child(3)){--items:2}.sm\:hover-gallery:has(>:nth-child(4)){--items:3}.sm\:hover-gallery:has(>:nth-child(5)){--items:4}.sm\:hover-gallery:has(>:nth-child(6)){--items:5}.sm\:hover-gallery:has(>:nth-child(7)){--items:6}.sm\:hover-gallery:has(>:nth-child(8)){--items:7}.sm\:hover-gallery:has(>:nth-child(9)){--items:8}.sm\:hover-gallery:has(>:nth-child(10)){--items:9}.sm\:hover-gallery>*{opacity:0;object-fit:cover;grid-row:1;width:100%;height:100%;&:first-child{opacity:1;grid-column:1/-1}&:nth-child(2){grid-column:1}&:nth-child(3){grid-column:2}&:nth-child(4){grid-column:3}&:nth-child(5){grid-column:4}&:nth-child(6){grid-column:5}&:nth-child(7){grid-column:6}&:nth-child(8){grid-column:7}&:nth-child(9){grid-column:8}&:nth-child(10){grid-column:9}&:nth-child(n+11){display:none}}.sm\:hover-gallery>:hover{opacity:1;grid-column:1/-1}.sm\:hover-gallery:has(:hover)>:first-child{display:none}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:hover-gallery{--items:1;grid-template-columns:repeat(var(--items), 1fr);gap:1px;width:100%;overflow:hidden}.hover-gallery,.md\:hover-gallery:is(figure){display:inline-grid}.md\:hover-gallery:has(>:nth-child(3)){--items:2}.md\:hover-gallery:has(>:nth-child(4)){--items:3}.md\:hover-gallery:has(>:nth-child(5)){--items:4}.md\:hover-gallery:has(>:nth-child(6)){--items:5}.md\:hover-gallery:has(>:nth-child(7)){--items:6}.md\:hover-gallery:has(>:nth-child(8)){--items:7}.md\:hover-gallery:has(>:nth-child(9)){--items:8}.md\:hover-gallery:has(>:nth-child(10)){--items:9}.md\:hover-gallery>*{opacity:0;object-fit:cover;grid-row:1;width:100%;height:100%;&:first-child{opacity:1;grid-column:1/-1}&:nth-child(2){grid-column:1}&:nth-child(3){grid-column:2}&:nth-child(4){grid-column:3}&:nth-child(5){grid-column:4}&:nth-child(6){grid-column:5}&:nth-child(7){grid-column:6}&:nth-child(8){grid-column:7}&:nth-child(9){grid-column:8}&:nth-child(10){grid-column:9}&:nth-child(n+11){display:none}}.md\:hover-gallery>:hover{opacity:1;grid-column:1/-1}.md\:hover-gallery:has(:hover)>:first-child{display:none}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:hover-gallery{--items:1;grid-template-columns:repeat(var(--items), 1fr);gap:1px;width:100%;overflow:hidden}.hover-gallery,.lg\:hover-gallery:is(figure){display:inline-grid}.lg\:hover-gallery:has(>:nth-child(3)){--items:2}.lg\:hover-gallery:has(>:nth-child(4)){--items:3}.lg\:hover-gallery:has(>:nth-child(5)){--items:4}.lg\:hover-gallery:has(>:nth-child(6)){--items:5}.lg\:hover-gallery:has(>:nth-child(7)){--items:6}.lg\:hover-gallery:has(>:nth-child(8)){--items:7}.lg\:hover-gallery:has(>:nth-child(9)){--items:8}.lg\:hover-gallery:has(>:nth-child(10)){--items:9}.lg\:hover-gallery>*{opacity:0;object-fit:cover;grid-row:1;width:100%;height:100%;&:first-child{opacity:1;grid-column:1/-1}&:nth-child(2){grid-column:1}&:nth-child(3){grid-column:2}&:nth-child(4){grid-column:3}&:nth-child(5){grid-column:4}&:nth-child(6){grid-column:5}&:nth-child(7){grid-column:6}&:nth-child(8){grid-column:7}&:nth-child(9){grid-column:8}&:nth-child(10){grid-column:9}&:nth-child(n+11){display:none}}.lg\:hover-gallery>:hover{opacity:1;grid-column:1/-1}.lg\:hover-gallery:has(:hover)>:first-child{display:none}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:hover-gallery{--items:1;grid-template-columns:repeat(var(--items), 1fr);gap:1px;width:100%;overflow:hidden}.hover-gallery,.xl\:hover-gallery:is(figure){display:inline-grid}.xl\:hover-gallery:has(>:nth-child(3)){--items:2}.xl\:hover-gallery:has(>:nth-child(4)){--items:3}.xl\:hover-gallery:has(>:nth-child(5)){--items:4}.xl\:hover-gallery:has(>:nth-child(6)){--items:5}.xl\:hover-gallery:has(>:nth-child(7)){--items:6}.xl\:hover-gallery:has(>:nth-child(8)){--items:7}.xl\:hover-gallery:has(>:nth-child(9)){--items:8}.xl\:hover-gallery:has(>:nth-child(10)){--items:9}.xl\:hover-gallery>*{opacity:0;object-fit:cover;grid-row:1;width:100%;height:100%;&:first-child{opacity:1;grid-column:1/-1}&:nth-child(2){grid-column:1}&:nth-child(3){grid-column:2}&:nth-child(4){grid-column:3}&:nth-child(5){grid-column:4}&:nth-child(6){grid-column:5}&:nth-child(7){grid-column:6}&:nth-child(8){grid-column:7}&:nth-child(9){grid-column:8}&:nth-child(10){grid-column:9}&:nth-child(n+11){display:none}}.xl\:hover-gallery>:hover{opacity:1;grid-column:1/-1}.xl\:hover-gallery:has(:hover)>:first-child{display:none}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:hover-gallery{--items:1;grid-template-columns:repeat(var(--items), 1fr);gap:1px;width:100%;overflow:hidden}.hover-gallery,.\32 xl\:hover-gallery:is(figure){display:inline-grid}.\32 xl\:hover-gallery:has(>:nth-child(3)){--items:2}.\32 xl\:hover-gallery:has(>:nth-child(4)){--items:3}.\32 xl\:hover-gallery:has(>:nth-child(5)){--items:4}.\32 xl\:hover-gallery:has(>:nth-child(6)){--items:5}.\32 xl\:hover-gallery:has(>:nth-child(7)){--items:6}.\32 xl\:hover-gallery:has(>:nth-child(8)){--items:7}.\32 xl\:hover-gallery:has(>:nth-child(9)){--items:8}.\32 xl\:hover-gallery:has(>:nth-child(10)){--items:9}.\32 xl\:hover-gallery>*{opacity:0;object-fit:cover;grid-row:1;width:100%;height:100%;&:first-child{opacity:1;grid-column:1/-1}&:nth-child(2){grid-column:1}&:nth-child(3){grid-column:2}&:nth-child(4){grid-column:3}&:nth-child(5){grid-column:4}&:nth-child(6){grid-column:5}&:nth-child(7){grid-column:6}&:nth-child(8){grid-column:7}&:nth-child(9){grid-column:8}&:nth-child(10){grid-column:9}&:nth-child(n+11){display:none}}.\32 xl\:hover-gallery>:hover{opacity:1;grid-column:1/-1}.\32 xl\:hover-gallery:has(:hover)>:first-child{display:none}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:textarea{appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}.sm\:textarea textarea{appearance:none;background-color:#0000;border:none;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}}.sm\:textarea:focus,.sm\:textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.sm\:textarea:focus,.sm\:textarea:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.sm\:textarea:has(>textarea[disabled]),.sm\:textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 40%, transparent);box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.sm\:textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.sm\:textarea-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.sm\:textarea-ghost:focus,.sm\:textarea-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.textarea-neutral,.sm\:textarea-neutral:focus,.sm\:textarea-neutral:focus-within{--input-color:var(--color-neutral)}.textarea-primary,.sm\:textarea-primary:focus,.sm\:textarea-primary:focus-within{--input-color:var(--color-primary)}.textarea-secondary,.sm\:textarea-secondary:focus,.sm\:textarea-secondary:focus-within{--input-color:var(--color-secondary)}.textarea-accent,.sm\:textarea-accent:focus,.sm\:textarea-accent:focus-within{--input-color:var(--color-accent)}.textarea-info,.sm\:textarea-info:focus,.sm\:textarea-info:focus-within{--input-color:var(--color-info)}.textarea-success,.sm\:textarea-success:focus,.sm\:textarea-success:focus-within{--input-color:var(--color-success)}.textarea-warning,.sm\:textarea-warning:focus,.sm\:textarea-warning:focus-within{--input-color:var(--color-warning)}.textarea-error,.sm\:textarea-error:focus,.sm\:textarea-error:focus-within{--input-color:var(--color-error)}.sm\:textarea-xs{--font-size-min:.6875rem}.sm\:floating-label:has(.textarea-xs){--top-mul:3;--font-size:.6875rem}.sm\:textarea-sm{--font-size-min:.75rem}.sm\:floating-label:has(.textarea-sm){--top-mul:4;--font-size:.75rem}.sm\:textarea-md{--font-size-min:.875rem}.sm\:floating-label:has(.textarea-md){--top-mul:5;--font-size:.875rem}.sm\:textarea-lg{--font-size-min:1.125rem}.sm\:floating-label:has(.textarea-lg){--top-mul:6;--font-size:1.125rem}.sm\:textarea-xl{--font-size-min:1.375rem}.sm\:floating-label:has(.textarea-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:textarea{appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}.md\:textarea textarea{appearance:none;background-color:#0000;border:none;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}}.md\:textarea:focus,.md\:textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.md\:textarea:focus,.md\:textarea:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.md\:textarea:has(>textarea[disabled]),.md\:textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 40%, transparent);box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.md\:textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.md\:textarea-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.md\:textarea-ghost:focus,.md\:textarea-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.textarea-neutral,.md\:textarea-neutral:focus,.md\:textarea-neutral:focus-within{--input-color:var(--color-neutral)}.textarea-primary,.md\:textarea-primary:focus,.md\:textarea-primary:focus-within{--input-color:var(--color-primary)}.textarea-secondary,.md\:textarea-secondary:focus,.md\:textarea-secondary:focus-within{--input-color:var(--color-secondary)}.textarea-accent,.md\:textarea-accent:focus,.md\:textarea-accent:focus-within{--input-color:var(--color-accent)}.textarea-info,.md\:textarea-info:focus,.md\:textarea-info:focus-within{--input-color:var(--color-info)}.textarea-success,.md\:textarea-success:focus,.md\:textarea-success:focus-within{--input-color:var(--color-success)}.textarea-warning,.md\:textarea-warning:focus,.md\:textarea-warning:focus-within{--input-color:var(--color-warning)}.textarea-error,.md\:textarea-error:focus,.md\:textarea-error:focus-within{--input-color:var(--color-error)}.md\:textarea-xs{--font-size-min:.6875rem}.md\:floating-label:has(.textarea-xs){--top-mul:3;--font-size:.6875rem}.md\:textarea-sm{--font-size-min:.75rem}.md\:floating-label:has(.textarea-sm){--top-mul:4;--font-size:.75rem}.md\:textarea-md{--font-size-min:.875rem}.md\:floating-label:has(.textarea-md){--top-mul:5;--font-size:.875rem}.md\:textarea-lg{--font-size-min:1.125rem}.md\:floating-label:has(.textarea-lg){--top-mul:6;--font-size:1.125rem}.md\:textarea-xl{--font-size-min:1.375rem}.md\:floating-label:has(.textarea-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:textarea{appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}.lg\:textarea textarea{appearance:none;background-color:#0000;border:none;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}}.lg\:textarea:focus,.lg\:textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.lg\:textarea:focus,.lg\:textarea:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.lg\:textarea:has(>textarea[disabled]),.lg\:textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 40%, transparent);box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.lg\:textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.lg\:textarea-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.lg\:textarea-ghost:focus,.lg\:textarea-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.textarea-neutral,.lg\:textarea-neutral:focus,.lg\:textarea-neutral:focus-within{--input-color:var(--color-neutral)}.textarea-primary,.lg\:textarea-primary:focus,.lg\:textarea-primary:focus-within{--input-color:var(--color-primary)}.textarea-secondary,.lg\:textarea-secondary:focus,.lg\:textarea-secondary:focus-within{--input-color:var(--color-secondary)}.textarea-accent,.lg\:textarea-accent:focus,.lg\:textarea-accent:focus-within{--input-color:var(--color-accent)}.textarea-info,.lg\:textarea-info:focus,.lg\:textarea-info:focus-within{--input-color:var(--color-info)}.textarea-success,.lg\:textarea-success:focus,.lg\:textarea-success:focus-within{--input-color:var(--color-success)}.textarea-warning,.lg\:textarea-warning:focus,.lg\:textarea-warning:focus-within{--input-color:var(--color-warning)}.textarea-error,.lg\:textarea-error:focus,.lg\:textarea-error:focus-within{--input-color:var(--color-error)}.lg\:textarea-xs{--font-size-min:.6875rem}.lg\:floating-label:has(.textarea-xs){--top-mul:3;--font-size:.6875rem}.lg\:textarea-sm{--font-size-min:.75rem}.lg\:floating-label:has(.textarea-sm){--top-mul:4;--font-size:.75rem}.lg\:textarea-md{--font-size-min:.875rem}.lg\:floating-label:has(.textarea-md){--top-mul:5;--font-size:.875rem}.lg\:textarea-lg{--font-size-min:1.125rem}.lg\:floating-label:has(.textarea-lg){--top-mul:6;--font-size:1.125rem}.lg\:textarea-xl{--font-size-min:1.375rem}.lg\:floating-label:has(.textarea-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:textarea{appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}.xl\:textarea textarea{appearance:none;background-color:#0000;border:none;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}}.xl\:textarea:focus,.xl\:textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.xl\:textarea:focus,.xl\:textarea:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.xl\:textarea:has(>textarea[disabled]),.xl\:textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 40%, transparent);box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.xl\:textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.xl\:textarea-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.xl\:textarea-ghost:focus,.xl\:textarea-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.textarea-neutral,.xl\:textarea-neutral:focus,.xl\:textarea-neutral:focus-within{--input-color:var(--color-neutral)}.textarea-primary,.xl\:textarea-primary:focus,.xl\:textarea-primary:focus-within{--input-color:var(--color-primary)}.textarea-secondary,.xl\:textarea-secondary:focus,.xl\:textarea-secondary:focus-within{--input-color:var(--color-secondary)}.textarea-accent,.xl\:textarea-accent:focus,.xl\:textarea-accent:focus-within{--input-color:var(--color-accent)}.textarea-info,.xl\:textarea-info:focus,.xl\:textarea-info:focus-within{--input-color:var(--color-info)}.textarea-success,.xl\:textarea-success:focus,.xl\:textarea-success:focus-within{--input-color:var(--color-success)}.textarea-warning,.xl\:textarea-warning:focus,.xl\:textarea-warning:focus-within{--input-color:var(--color-warning)}.textarea-error,.xl\:textarea-error:focus,.xl\:textarea-error:focus-within{--input-color:var(--color-error)}.xl\:textarea-xs{--font-size-min:.6875rem}.xl\:floating-label:has(.textarea-xs){--top-mul:3;--font-size:.6875rem}.xl\:textarea-sm{--font-size-min:.75rem}.xl\:floating-label:has(.textarea-sm){--top-mul:4;--font-size:.75rem}.xl\:textarea-md{--font-size-min:.875rem}.xl\:floating-label:has(.textarea-md){--top-mul:5;--font-size:.875rem}.xl\:textarea-lg{--font-size-min:1.125rem}.xl\:floating-label:has(.textarea-lg){--top-mul:6;--font-size:1.125rem}.xl\:textarea-xl{--font-size-min:1.375rem}.xl\:floating-label:has(.textarea-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:textarea{appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000);width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,0rem), var(--font-size-min,.875rem));touch-action:manipulation;border:var(--border) solid var(--input-color,#0000);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}.\32 xl\:textarea textarea{appearance:none;background-color:#0000;border:none;&::placeholder{color:var(--color-base-content);opacity:.5}&:focus,&:focus-within{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}}.\32 xl\:textarea:focus,.\32 xl\:textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.\32 xl\:textarea:focus,.\32 xl\:textarea:focus-within{--font-size:1rem}}}}@layer daisyui.l1.l2{.\32 xl\:textarea:has(>textarea[disabled]),.\32 xl\:textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:color-mix(in oklab, var(--color-base-content) 40%, transparent);box-shadow:none;&::placeholder,& ::placeholder{color:var(--color-base-content);opacity:.2}}.\32 xl\:textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.\32 xl\:textarea-ghost{box-shadow:none;background-color:#0000;border-color:#0000}.\32 xl\:textarea-ghost:focus,.\32 xl\:textarea-ghost:focus-within{background-color:var(--color-base-100);color:var(--color-base-content);box-shadow:none;border-color:#0000}.textarea-neutral,.\32 xl\:textarea-neutral:focus,.\32 xl\:textarea-neutral:focus-within{--input-color:var(--color-neutral)}.textarea-primary,.\32 xl\:textarea-primary:focus,.\32 xl\:textarea-primary:focus-within{--input-color:var(--color-primary)}.textarea-secondary,.\32 xl\:textarea-secondary:focus,.\32 xl\:textarea-secondary:focus-within{--input-color:var(--color-secondary)}.textarea-accent,.\32 xl\:textarea-accent:focus,.\32 xl\:textarea-accent:focus-within{--input-color:var(--color-accent)}.textarea-info,.\32 xl\:textarea-info:focus,.\32 xl\:textarea-info:focus-within{--input-color:var(--color-info)}.textarea-success,.\32 xl\:textarea-success:focus,.\32 xl\:textarea-success:focus-within{--input-color:var(--color-success)}.textarea-warning,.\32 xl\:textarea-warning:focus,.\32 xl\:textarea-warning:focus-within{--input-color:var(--color-warning)}.textarea-error,.\32 xl\:textarea-error:focus,.\32 xl\:textarea-error:focus-within{--input-color:var(--color-error)}.\32 xl\:textarea-xs{--font-size-min:.6875rem}.\32 xl\:floating-label:has(.textarea-xs){--top-mul:3;--font-size:.6875rem}.\32 xl\:textarea-sm{--font-size-min:.75rem}.\32 xl\:floating-label:has(.textarea-sm){--top-mul:4;--font-size:.75rem}.\32 xl\:textarea-md{--font-size-min:.875rem}.\32 xl\:floating-label:has(.textarea-md){--top-mul:5;--font-size:.875rem}.\32 xl\:textarea-lg{--font-size-min:1.125rem}.\32 xl\:floating-label:has(.textarea-lg){--top-mul:6;--font-size:1.125rem}.\32 xl\:textarea-xl{--font-size-min:1.375rem}.\32 xl\:floating-label:has(.textarea-xl){--top-mul:7;--font-size:1.375rem}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:timeline{display:flex;position:relative}.sm\:timeline>li{grid-template-rows:var(--timeline-row-start,minmax(0, 1fr)) auto var(--timeline-row-end,minmax(0, 1fr));grid-template-columns:var(--timeline-col-start,minmax(0, 1fr)) auto var(--timeline-col-end,minmax(0, 1fr));flex-shrink:0;align-items:center;display:grid;position:relative;&>hr{border:none;width:100%;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}@media print{border:.1px solid var(--color-base-300)}}}.sm\:timeline :where(hr){background-color:var(--color-base-300);height:.25rem}.sm\:timeline:has(.timeline-middle hr):first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.sm\:timeline:has(.timeline-middle hr):last-child,.sm\:timeline:not(:has(.timeline-middle)) :first-child hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.sm\:timeline:not(:has(.timeline-middle)) :last-child hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.sm\:timeline-box{border:var(--border) solid;border-radius:var(--radius-box);border-color:var(--color-base-300);background-color:var(--color-base-100);padding-block:.5rem;padding-inline:1rem;font-size:.75rem;box-shadow:0 1px 2px oklch(0% 0 0/.05)}.sm\:timeline-start{grid-area:1/1/2/4;place-self:flex-end center;margin:.25rem}.sm\:timeline-middle{grid-row-start:2;grid-column-start:2}.sm\:timeline-end{grid-area:3/1/4/4;place-self:flex-start center;margin:.25rem}}@layer daisyui.l1.l2{.sm\:timeline-compact{--timeline-row-start:0}.sm\:timeline-compact .timeline-start{grid-area:3/1/4/4;place-self:flex-start center}.sm\:timeline-compact li:has(.timeline-start) .timeline-end{grid-row-start:auto;grid-column-start:none}.sm\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.sm\:timeline-compact.timeline-vertical .timeline-start{grid-area:1/3/4/4;place-self:center flex-start}.sm\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-row-start:none;grid-column-start:auto}.sm\:timeline-snap-icon>li{--timeline-col-start:.5rem;--timeline-row-start:minmax(0, 1fr)}.sm\:timeline-vertical{flex-direction:column}.sm\:timeline-vertical>li{--timeline-row-start:minmax(0, 1fr);--timeline-row-end:minmax(0, 1fr);justify-items:center;&>hr{width:.25rem;height:100%;&:first-child{grid-row-start:1;grid-column-start:2}&:last-child{grid-area:3/2/none}}}.sm\:timeline-vertical .timeline-start{grid-area:1/1/4/2;place-self:center flex-end}.sm\:timeline-vertical .timeline-end{grid-area:1/3/4/4;place-self:center flex-start}.sm\:timeline-vertical:has(.timeline-middle)>li>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.sm\:timeline-vertical:has(.timeline-middle)>li>hr:last-child,.sm\:timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector);border-bottom-right-radius:0;border-bottom-left-radius:0}.sm\:timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.sm\:timeline-vertical.timeline-snap-icon>li{--timeline-col-start:minmax(0, 1fr);--timeline-row-start:.5rem}.sm\:timeline-horizontal{flex-direction:row}.sm\:timeline-horizontal>li{align-items:center;&>hr{width:100%;height:.25rem;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}}}.sm\:timeline-horizontal .timeline-start{grid-area:1/1/2/4;place-self:flex-end center}.sm\:timeline-horizontal .timeline-end{grid-area:3/1/4/4;place-self:flex-start center}.sm\:timeline-horizontal:has(.timeline-middle)>li>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.sm\:timeline-horizontal:has(.timeline-middle)>li>hr:last-child,.sm\:timeline-horizontal:not(:has(.timeline-middle)) :first-child>hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.sm\:timeline-horizontal:not(:has(.timeline-middle)) :last-child>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:timeline{display:flex;position:relative}.md\:timeline>li{grid-template-rows:var(--timeline-row-start,minmax(0, 1fr)) auto var(--timeline-row-end,minmax(0, 1fr));grid-template-columns:var(--timeline-col-start,minmax(0, 1fr)) auto var(--timeline-col-end,minmax(0, 1fr));flex-shrink:0;align-items:center;display:grid;position:relative;&>hr{border:none;width:100%;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}@media print{border:.1px solid var(--color-base-300)}}}.md\:timeline :where(hr){background-color:var(--color-base-300);height:.25rem}.md\:timeline:has(.timeline-middle hr):first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.md\:timeline:has(.timeline-middle hr):last-child,.md\:timeline:not(:has(.timeline-middle)) :first-child hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.md\:timeline:not(:has(.timeline-middle)) :last-child hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.md\:timeline-box{border:var(--border) solid;border-radius:var(--radius-box);border-color:var(--color-base-300);background-color:var(--color-base-100);padding-block:.5rem;padding-inline:1rem;font-size:.75rem;box-shadow:0 1px 2px oklch(0% 0 0/.05)}.md\:timeline-start{grid-area:1/1/2/4;place-self:flex-end center;margin:.25rem}.md\:timeline-middle{grid-row-start:2;grid-column-start:2}.md\:timeline-end{grid-area:3/1/4/4;place-self:flex-start center;margin:.25rem}}@layer daisyui.l1.l2{.md\:timeline-compact{--timeline-row-start:0}.md\:timeline-compact .timeline-start{grid-area:3/1/4/4;place-self:flex-start center}.md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-row-start:auto;grid-column-start:none}.md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.md\:timeline-compact.timeline-vertical .timeline-start{grid-area:1/3/4/4;place-self:center flex-start}.md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-row-start:none;grid-column-start:auto}.md\:timeline-snap-icon>li{--timeline-col-start:.5rem;--timeline-row-start:minmax(0, 1fr)}.md\:timeline-vertical{flex-direction:column}.md\:timeline-vertical>li{--timeline-row-start:minmax(0, 1fr);--timeline-row-end:minmax(0, 1fr);justify-items:center;&>hr{width:.25rem;height:100%;&:first-child{grid-row-start:1;grid-column-start:2}&:last-child{grid-area:3/2/none}}}.md\:timeline-vertical .timeline-start{grid-area:1/1/4/2;place-self:center flex-end}.md\:timeline-vertical .timeline-end{grid-area:1/3/4/4;place-self:center flex-start}.md\:timeline-vertical:has(.timeline-middle)>li>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.md\:timeline-vertical:has(.timeline-middle)>li>hr:last-child,.md\:timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector);border-bottom-right-radius:0;border-bottom-left-radius:0}.md\:timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.md\:timeline-vertical.timeline-snap-icon>li{--timeline-col-start:minmax(0, 1fr);--timeline-row-start:.5rem}.md\:timeline-horizontal{flex-direction:row}.md\:timeline-horizontal>li{align-items:center;&>hr{width:100%;height:.25rem;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}}}.md\:timeline-horizontal .timeline-start{grid-area:1/1/2/4;place-self:flex-end center}.md\:timeline-horizontal .timeline-end{grid-area:3/1/4/4;place-self:flex-start center}.md\:timeline-horizontal:has(.timeline-middle)>li>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.md\:timeline-horizontal:has(.timeline-middle)>li>hr:last-child,.md\:timeline-horizontal:not(:has(.timeline-middle)) :first-child>hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.md\:timeline-horizontal:not(:has(.timeline-middle)) :last-child>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:timeline{display:flex;position:relative}.lg\:timeline>li{grid-template-rows:var(--timeline-row-start,minmax(0, 1fr)) auto var(--timeline-row-end,minmax(0, 1fr));grid-template-columns:var(--timeline-col-start,minmax(0, 1fr)) auto var(--timeline-col-end,minmax(0, 1fr));flex-shrink:0;align-items:center;display:grid;position:relative;&>hr{border:none;width:100%;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}@media print{border:.1px solid var(--color-base-300)}}}.lg\:timeline :where(hr){background-color:var(--color-base-300);height:.25rem}.lg\:timeline:has(.timeline-middle hr):first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.lg\:timeline:has(.timeline-middle hr):last-child,.lg\:timeline:not(:has(.timeline-middle)) :first-child hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.lg\:timeline:not(:has(.timeline-middle)) :last-child hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.lg\:timeline-box{border:var(--border) solid;border-radius:var(--radius-box);border-color:var(--color-base-300);background-color:var(--color-base-100);padding-block:.5rem;padding-inline:1rem;font-size:.75rem;box-shadow:0 1px 2px oklch(0% 0 0/.05)}.lg\:timeline-start{grid-area:1/1/2/4;place-self:flex-end center;margin:.25rem}.lg\:timeline-middle{grid-row-start:2;grid-column-start:2}.lg\:timeline-end{grid-area:3/1/4/4;place-self:flex-start center;margin:.25rem}}@layer daisyui.l1.l2{.lg\:timeline-compact{--timeline-row-start:0}.lg\:timeline-compact .timeline-start{grid-area:3/1/4/4;place-self:flex-start center}.lg\:timeline-compact li:has(.timeline-start) .timeline-end{grid-row-start:auto;grid-column-start:none}.lg\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.lg\:timeline-compact.timeline-vertical .timeline-start{grid-area:1/3/4/4;place-self:center flex-start}.lg\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-row-start:none;grid-column-start:auto}.lg\:timeline-snap-icon>li{--timeline-col-start:.5rem;--timeline-row-start:minmax(0, 1fr)}.lg\:timeline-vertical{flex-direction:column}.lg\:timeline-vertical>li{--timeline-row-start:minmax(0, 1fr);--timeline-row-end:minmax(0, 1fr);justify-items:center;&>hr{width:.25rem;height:100%;&:first-child{grid-row-start:1;grid-column-start:2}&:last-child{grid-area:3/2/none}}}.lg\:timeline-vertical .timeline-start{grid-area:1/1/4/2;place-self:center flex-end}.lg\:timeline-vertical .timeline-end{grid-area:1/3/4/4;place-self:center flex-start}.lg\:timeline-vertical:has(.timeline-middle)>li>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.lg\:timeline-vertical:has(.timeline-middle)>li>hr:last-child,.lg\:timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector);border-bottom-right-radius:0;border-bottom-left-radius:0}.lg\:timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.lg\:timeline-vertical.timeline-snap-icon>li{--timeline-col-start:minmax(0, 1fr);--timeline-row-start:.5rem}.lg\:timeline-horizontal{flex-direction:row}.lg\:timeline-horizontal>li{align-items:center;&>hr{width:100%;height:.25rem;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}}}.lg\:timeline-horizontal .timeline-start{grid-area:1/1/2/4;place-self:flex-end center}.lg\:timeline-horizontal .timeline-end{grid-area:3/1/4/4;place-self:flex-start center}.lg\:timeline-horizontal:has(.timeline-middle)>li>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.lg\:timeline-horizontal:has(.timeline-middle)>li>hr:last-child,.lg\:timeline-horizontal:not(:has(.timeline-middle)) :first-child>hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.lg\:timeline-horizontal:not(:has(.timeline-middle)) :last-child>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:timeline{display:flex;position:relative}.xl\:timeline>li{grid-template-rows:var(--timeline-row-start,minmax(0, 1fr)) auto var(--timeline-row-end,minmax(0, 1fr));grid-template-columns:var(--timeline-col-start,minmax(0, 1fr)) auto var(--timeline-col-end,minmax(0, 1fr));flex-shrink:0;align-items:center;display:grid;position:relative;&>hr{border:none;width:100%;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}@media print{border:.1px solid var(--color-base-300)}}}.xl\:timeline :where(hr){background-color:var(--color-base-300);height:.25rem}.xl\:timeline:has(.timeline-middle hr):first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.xl\:timeline:has(.timeline-middle hr):last-child,.xl\:timeline:not(:has(.timeline-middle)) :first-child hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.xl\:timeline:not(:has(.timeline-middle)) :last-child hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.xl\:timeline-box{border:var(--border) solid;border-radius:var(--radius-box);border-color:var(--color-base-300);background-color:var(--color-base-100);padding-block:.5rem;padding-inline:1rem;font-size:.75rem;box-shadow:0 1px 2px oklch(0% 0 0/.05)}.xl\:timeline-start{grid-area:1/1/2/4;place-self:flex-end center;margin:.25rem}.xl\:timeline-middle{grid-row-start:2;grid-column-start:2}.xl\:timeline-end{grid-area:3/1/4/4;place-self:flex-start center;margin:.25rem}}@layer daisyui.l1.l2{.xl\:timeline-compact{--timeline-row-start:0}.xl\:timeline-compact .timeline-start{grid-area:3/1/4/4;place-self:flex-start center}.xl\:timeline-compact li:has(.timeline-start) .timeline-end{grid-row-start:auto;grid-column-start:none}.xl\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.xl\:timeline-compact.timeline-vertical .timeline-start{grid-area:1/3/4/4;place-self:center flex-start}.xl\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-row-start:none;grid-column-start:auto}.xl\:timeline-snap-icon>li{--timeline-col-start:.5rem;--timeline-row-start:minmax(0, 1fr)}.xl\:timeline-vertical{flex-direction:column}.xl\:timeline-vertical>li{--timeline-row-start:minmax(0, 1fr);--timeline-row-end:minmax(0, 1fr);justify-items:center;&>hr{width:.25rem;height:100%;&:first-child{grid-row-start:1;grid-column-start:2}&:last-child{grid-area:3/2/none}}}.xl\:timeline-vertical .timeline-start{grid-area:1/1/4/2;place-self:center flex-end}.xl\:timeline-vertical .timeline-end{grid-area:1/3/4/4;place-self:center flex-start}.xl\:timeline-vertical:has(.timeline-middle)>li>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.xl\:timeline-vertical:has(.timeline-middle)>li>hr:last-child,.xl\:timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector);border-bottom-right-radius:0;border-bottom-left-radius:0}.xl\:timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.xl\:timeline-vertical.timeline-snap-icon>li{--timeline-col-start:minmax(0, 1fr);--timeline-row-start:.5rem}.xl\:timeline-horizontal{flex-direction:row}.xl\:timeline-horizontal>li{align-items:center;&>hr{width:100%;height:.25rem;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}}}.xl\:timeline-horizontal .timeline-start{grid-area:1/1/2/4;place-self:flex-end center}.xl\:timeline-horizontal .timeline-end{grid-area:3/1/4/4;place-self:flex-start center}.xl\:timeline-horizontal:has(.timeline-middle)>li>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.xl\:timeline-horizontal:has(.timeline-middle)>li>hr:last-child,.xl\:timeline-horizontal:not(:has(.timeline-middle)) :first-child>hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.xl\:timeline-horizontal:not(:has(.timeline-middle)) :last-child>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:timeline{display:flex;position:relative}.\32 xl\:timeline>li{grid-template-rows:var(--timeline-row-start,minmax(0, 1fr)) auto var(--timeline-row-end,minmax(0, 1fr));grid-template-columns:var(--timeline-col-start,minmax(0, 1fr)) auto var(--timeline-col-end,minmax(0, 1fr));flex-shrink:0;align-items:center;display:grid;position:relative;&>hr{border:none;width:100%;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}@media print{border:.1px solid var(--color-base-300)}}}.\32 xl\:timeline :where(hr){background-color:var(--color-base-300);height:.25rem}.\32 xl\:timeline:has(.timeline-middle hr):first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.\32 xl\:timeline:has(.timeline-middle hr):last-child,.\32 xl\:timeline:not(:has(.timeline-middle)) :first-child hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.\32 xl\:timeline:not(:has(.timeline-middle)) :last-child hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.\32 xl\:timeline-box{border:var(--border) solid;border-radius:var(--radius-box);border-color:var(--color-base-300);background-color:var(--color-base-100);padding-block:.5rem;padding-inline:1rem;font-size:.75rem;box-shadow:0 1px 2px oklch(0% 0 0/.05)}.\32 xl\:timeline-start{grid-area:1/1/2/4;place-self:flex-end center;margin:.25rem}.\32 xl\:timeline-middle{grid-row-start:2;grid-column-start:2}.\32 xl\:timeline-end{grid-area:3/1/4/4;place-self:flex-start center;margin:.25rem}}@layer daisyui.l1.l2{.\32 xl\:timeline-compact{--timeline-row-start:0}.\32 xl\:timeline-compact .timeline-start{grid-area:3/1/4/4;place-self:flex-start center}.\32 xl\:timeline-compact li:has(.timeline-start) .timeline-end{grid-row-start:auto;grid-column-start:none}.\32 xl\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.\32 xl\:timeline-compact.timeline-vertical .timeline-start{grid-area:1/3/4/4;place-self:center flex-start}.\32 xl\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-row-start:none;grid-column-start:auto}.\32 xl\:timeline-snap-icon>li{--timeline-col-start:.5rem;--timeline-row-start:minmax(0, 1fr)}.\32 xl\:timeline-vertical{flex-direction:column}.\32 xl\:timeline-vertical>li{--timeline-row-start:minmax(0, 1fr);--timeline-row-end:minmax(0, 1fr);justify-items:center;&>hr{width:.25rem;height:100%;&:first-child{grid-row-start:1;grid-column-start:2}&:last-child{grid-area:3/2/none}}}.\32 xl\:timeline-vertical .timeline-start{grid-area:1/1/4/2;place-self:center flex-end}.\32 xl\:timeline-vertical .timeline-end{grid-area:1/3/4/4;place-self:center flex-start}.\32 xl\:timeline-vertical:has(.timeline-middle)>li>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.\32 xl\:timeline-vertical:has(.timeline-middle)>li>hr:last-child,.\32 xl\:timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector);border-bottom-right-radius:0;border-bottom-left-radius:0}.\32 xl\:timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child{border-top-left-radius:0;border-top-right-radius:0;border-bottom-right-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.\32 xl\:timeline-vertical.timeline-snap-icon>li{--timeline-col-start:minmax(0, 1fr);--timeline-row-start:.5rem}.\32 xl\:timeline-horizontal{flex-direction:row}.\32 xl\:timeline-horizontal>li{align-items:center;&>hr{width:100%;height:.25rem;&:first-child{grid-row-start:2;grid-column-start:1}&:last-child{grid-area:2/3/auto/none}}}.\32 xl\:timeline-horizontal .timeline-start{grid-area:1/1/2/4;place-self:flex-end center}.\32 xl\:timeline-horizontal .timeline-end{grid-area:3/1/4/4;place-self:flex-start center}.\32 xl\:timeline-horizontal:has(.timeline-middle)>li>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}.\32 xl\:timeline-horizontal:has(.timeline-middle)>li>hr:last-child,.\32 xl\:timeline-horizontal:not(:has(.timeline-middle)) :first-child>hr:last-child{border-start-start-radius:var(--radius-selector);border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:var(--radius-selector)}.\32 xl\:timeline-horizontal:not(:has(.timeline-middle)) :last-child>hr:first-child{border-start-start-radius:0;border-start-end-radius:var(--radius-selector);border-end-end-radius:var(--radius-selector);border-end-start-radius:0}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.sm\:stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.sm\:stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.sm\:stat-figure{grid-row:1/span 3;grid-column-start:2;place-self:center flex-end}.sm\:stat-title{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.sm\:stat-value{white-space:nowrap;grid-column-start:1;font-size:2rem;font-weight:800}.sm\:stat-desc{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.sm\:stat-actions{white-space:nowrap;grid-column-start:1}}@layer daisyui.l1.l2{.sm\:stats-horizontal{grid-auto-flow:column;overflow-x:auto}.sm\:stats-horizontal .stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.sm\:stats-vertical{grid-auto-flow:row;overflow-y:auto}.sm\:stats-vertical .stat:not(:last-child){border-inline-end:none;border-block-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.md\:stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.md\:stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.md\:stat-figure{grid-row:1/span 3;grid-column-start:2;place-self:center flex-end}.md\:stat-title{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.md\:stat-value{white-space:nowrap;grid-column-start:1;font-size:2rem;font-weight:800}.md\:stat-desc{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.md\:stat-actions{white-space:nowrap;grid-column-start:1}}@layer daisyui.l1.l2{.md\:stats-horizontal{grid-auto-flow:column;overflow-x:auto}.md\:stats-horizontal .stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.md\:stats-vertical{grid-auto-flow:row;overflow-y:auto}.md\:stats-vertical .stat:not(:last-child){border-inline-end:none;border-block-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.lg\:stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.lg\:stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.lg\:stat-figure{grid-row:1/span 3;grid-column-start:2;place-self:center flex-end}.lg\:stat-title{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.lg\:stat-value{white-space:nowrap;grid-column-start:1;font-size:2rem;font-weight:800}.lg\:stat-desc{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.lg\:stat-actions{white-space:nowrap;grid-column-start:1}}@layer daisyui.l1.l2{.lg\:stats-horizontal{grid-auto-flow:column;overflow-x:auto}.lg\:stats-horizontal .stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.lg\:stats-vertical{grid-auto-flow:row;overflow-y:auto}.lg\:stats-vertical .stat:not(:last-child){border-inline-end:none;border-block-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.xl\:stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.xl\:stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.xl\:stat-figure{grid-row:1/span 3;grid-column-start:2;place-self:center flex-end}.xl\:stat-title{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.xl\:stat-value{white-space:nowrap;grid-column-start:1;font-size:2rem;font-weight:800}.xl\:stat-desc{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.xl\:stat-actions{white-space:nowrap;grid-column-start:1}}@layer daisyui.l1.l2{.xl\:stats-horizontal{grid-auto-flow:column;overflow-x:auto}.xl\:stats-horizontal .stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.xl\:stats-vertical{grid-auto-flow:row;overflow-y:auto}.xl\:stats-vertical .stat:not(:last-child){border-inline-end:none;border-block-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.\32 xl\:stat{grid-template-columns:repeat(1,1fr);column-gap:1rem;width:100%;padding-block:1rem;padding-inline:1.5rem;display:inline-grid}.\32 xl\:stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.\32 xl\:stat-figure{grid-row:1/span 3;grid-column-start:2;place-self:center flex-end}.\32 xl\:stat-title{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.\32 xl\:stat-value{white-space:nowrap;grid-column-start:1;font-size:2rem;font-weight:800}.\32 xl\:stat-desc{white-space:nowrap;color:color-mix(in oklab, var(--color-base-content) 60%, transparent);grid-column-start:1;font-size:.75rem}.\32 xl\:stat-actions{white-space:nowrap;grid-column-start:1}}@layer daisyui.l1.l2{.\32 xl\:stats-horizontal{grid-auto-flow:column;overflow-x:auto}.\32 xl\:stats-horizontal .stat:not(:last-child){border-inline-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000);border-block-end:none}.\32 xl\:stats-vertical{grid-auto-flow:row;overflow-y:auto}.\32 xl\:stats-vertical .stat:not(:last-child){border-inline-end:none;border-block-end:var(--border) dashed color-mix(in oklab, currentColor 10%, #0000)}}}.prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}@layer daisyui{.btn-link{--btn-bg:#0000;color:var(--btn-color,var(--color-primary));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none;text-decoration-line:underline}.btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]){pointer-events:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);--btn-bg:#0000;--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]):not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}@media (width>=640px){@layer daisyui.l1.l2.l3{:where(.btn){width:unset}.sm\:btn{--size:calc(var(--size-field,.25rem) * 10);--btn-p:1rem;--btn-fg:var(--color-base-content);cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;-webkit-user-select:none;user-select:none;border-width:var(--border);touch-action:manipulation;--btn-bg:var(--btn-color,var(--color-base-200));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 calc(var(--depth) * 5%));--btn-soft-bg:initial;--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));height:var(--size);padding-inline:var(--btn-p);font-weight:600;font-size:var(--fontsize,.875rem);background-color:var(--btn-bg);color:var(--btn-fg);border-color:var(--btn-border);border-style:var(--btn-border-style,solid);outline-color:var(--btn-color,var(--color-base-content));--tw-prose-links:var(--btn-fg);background-image:none, var(--fx-noise);background-size:auto, calc(var(--noise,0) * 100%);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));box-shadow:var(--btn-inset) inset, var(--btn-shadow);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;transition-property:color,background-color,border-color,box-shadow,transform;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}.sm\:btn:is([type=checkbox],[type=radio]){appearance:none;&[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}}.sm\:btn:where(:checked:not(.filter [type=radio].btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.sm\:btn-outline,.sm\:btn-dash{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:var(--btn-color,var(--color-base-content));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.sm\:btn-dash{--btn-border-style:dashed}.sm\:btn-ghost{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content,currentColor)));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.sm\:btn-soft{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--btn-soft-bg,var(--color-base-100)));color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--btn-soft-bg,var(--color-base-100)));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}}.sm\:prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}@layer daisyui.l1{@media (hover:hover){.sm\:btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);color:var(--btn-fg);--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.sm\:btn:active:not(.btn-active,[aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);translate:0 .5px}.sm\:btn:where(:checked:not(.filter [type=radio].btn),:not([type=radio],[type=checkbox]):focus-visible){--btn-bg:var(--btn-color,var(--color-base-200));color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);isolation:isolate}.sm\:btn:focus-visible,.sm\:btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}}@layer daisyui.l1.l2{.sm\:btn-active,.sm\:btn:is([aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);isolation:isolate}.sm\:btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);--btn-soft-bg:initial}.sm\:btn-secondary{--btn-color:var(--color-secondary);--btn-fg:var(--color-secondary-content);--btn-soft-bg:initial}.sm\:btn-accent{--btn-color:var(--color-accent);--btn-fg:var(--color-accent-content);--btn-soft-bg:initial}.sm\:btn-neutral{--btn-color:var(--color-neutral);--btn-fg:var(--color-neutral-content);--btn-soft-bg:var(--color-neutral-content) 80%;--btn-rest-fg:initial}.sm\:btn-info{--btn-color:var(--color-info);--btn-fg:var(--color-info-content);--btn-soft-bg:initial}.sm\:btn-success{--btn-color:var(--color-success);--btn-fg:var(--color-success-content);--btn-soft-bg:initial}.sm\:btn-warning{--btn-color:var(--color-warning);--btn-fg:var(--color-warning-content);--btn-soft-bg:initial}.sm\:btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content);--btn-soft-bg:initial}.sm\:btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.sm\:btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.sm\:btn-md{--fontsize:.875rem;--btn-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.sm\:btn-lg{--fontsize:1.125rem;--btn-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.sm\:btn-xl{--fontsize:1.375rem;--btn-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}.sm\:btn-wide{width:100%;max-width:16rem}.sm\:btn-block{width:100%}.sm\:btn-square{width:var(--size);height:var(--size);padding-inline:0}.sm\:btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}}@layer daisyui{.sm\:btn-link{--btn-bg:#0000;color:var(--btn-color,var(--color-primary));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none;text-decoration-line:underline}.sm\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]){pointer-events:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);--btn-bg:#0000;--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.sm\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]):not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{:where(.btn){width:unset}.md\:btn{--size:calc(var(--size-field,.25rem) * 10);--btn-p:1rem;--btn-fg:var(--color-base-content);cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;-webkit-user-select:none;user-select:none;border-width:var(--border);touch-action:manipulation;--btn-bg:var(--btn-color,var(--color-base-200));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 calc(var(--depth) * 5%));--btn-soft-bg:initial;--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));height:var(--size);padding-inline:var(--btn-p);font-weight:600;font-size:var(--fontsize,.875rem);background-color:var(--btn-bg);color:var(--btn-fg);border-color:var(--btn-border);border-style:var(--btn-border-style,solid);outline-color:var(--btn-color,var(--color-base-content));--tw-prose-links:var(--btn-fg);background-image:none, var(--fx-noise);background-size:auto, calc(var(--noise,0) * 100%);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));box-shadow:var(--btn-inset) inset, var(--btn-shadow);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;transition-property:color,background-color,border-color,box-shadow,transform;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}.md\:btn:is([type=checkbox],[type=radio]){appearance:none;&[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}}.md\:btn:where(:checked:not(.filter [type=radio].btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.md\:btn-outline,.md\:btn-dash{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:var(--btn-color,var(--color-base-content));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.md\:btn-dash{--btn-border-style:dashed}.md\:btn-ghost{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content,currentColor)));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.md\:btn-soft{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--btn-soft-bg,var(--color-base-100)));color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--btn-soft-bg,var(--color-base-100)));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}}.md\:prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}@layer daisyui.l1{@media (hover:hover){.md\:btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);color:var(--btn-fg);--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.md\:btn:active:not(.btn-active,[aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);translate:0 .5px}.md\:btn:where(:checked:not(.filter [type=radio].btn),:not([type=radio],[type=checkbox]):focus-visible){--btn-bg:var(--btn-color,var(--color-base-200));color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);isolation:isolate}.md\:btn:focus-visible,.md\:btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}}@layer daisyui.l1.l2{.md\:btn-active,.md\:btn:is([aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);isolation:isolate}.md\:btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);--btn-soft-bg:initial}.md\:btn-secondary{--btn-color:var(--color-secondary);--btn-fg:var(--color-secondary-content);--btn-soft-bg:initial}.md\:btn-accent{--btn-color:var(--color-accent);--btn-fg:var(--color-accent-content);--btn-soft-bg:initial}.md\:btn-neutral{--btn-color:var(--color-neutral);--btn-fg:var(--color-neutral-content);--btn-soft-bg:var(--color-neutral-content) 80%;--btn-rest-fg:initial}.md\:btn-info{--btn-color:var(--color-info);--btn-fg:var(--color-info-content);--btn-soft-bg:initial}.md\:btn-success{--btn-color:var(--color-success);--btn-fg:var(--color-success-content);--btn-soft-bg:initial}.md\:btn-warning{--btn-color:var(--color-warning);--btn-fg:var(--color-warning-content);--btn-soft-bg:initial}.md\:btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content);--btn-soft-bg:initial}.md\:btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.md\:btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.md\:btn-md{--fontsize:.875rem;--btn-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.md\:btn-lg{--fontsize:1.125rem;--btn-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.md\:btn-xl{--fontsize:1.375rem;--btn-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}.md\:btn-wide{width:100%;max-width:16rem}.md\:btn-block{width:100%}.md\:btn-square{width:var(--size);height:var(--size);padding-inline:0}.md\:btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}}@layer daisyui{.md\:btn-link{--btn-bg:#0000;color:var(--btn-color,var(--color-primary));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none;text-decoration-line:underline}.md\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]){pointer-events:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);--btn-bg:#0000;--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.md\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]):not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{:where(.btn){width:unset}.lg\:btn{--size:calc(var(--size-field,.25rem) * 10);--btn-p:1rem;--btn-fg:var(--color-base-content);cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;-webkit-user-select:none;user-select:none;border-width:var(--border);touch-action:manipulation;--btn-bg:var(--btn-color,var(--color-base-200));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 calc(var(--depth) * 5%));--btn-soft-bg:initial;--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));height:var(--size);padding-inline:var(--btn-p);font-weight:600;font-size:var(--fontsize,.875rem);background-color:var(--btn-bg);color:var(--btn-fg);border-color:var(--btn-border);border-style:var(--btn-border-style,solid);outline-color:var(--btn-color,var(--color-base-content));--tw-prose-links:var(--btn-fg);background-image:none, var(--fx-noise);background-size:auto, calc(var(--noise,0) * 100%);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));box-shadow:var(--btn-inset) inset, var(--btn-shadow);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;transition-property:color,background-color,border-color,box-shadow,transform;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}.lg\:btn:is([type=checkbox],[type=radio]){appearance:none;&[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}}.lg\:btn:where(:checked:not(.filter [type=radio].btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.lg\:btn-outline,.lg\:btn-dash{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:var(--btn-color,var(--color-base-content));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.lg\:btn-dash{--btn-border-style:dashed}.lg\:btn-ghost{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content,currentColor)));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.lg\:btn-soft{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--btn-soft-bg,var(--color-base-100)));color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--btn-soft-bg,var(--color-base-100)));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}}.lg\:prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}@layer daisyui.l1{@media (hover:hover){.lg\:btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);color:var(--btn-fg);--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.lg\:btn:active:not(.btn-active,[aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);translate:0 .5px}.lg\:btn:where(:checked:not(.filter [type=radio].btn),:not([type=radio],[type=checkbox]):focus-visible){--btn-bg:var(--btn-color,var(--color-base-200));color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);isolation:isolate}.lg\:btn:focus-visible,.lg\:btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}}@layer daisyui.l1.l2{.lg\:btn-active,.lg\:btn:is([aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);isolation:isolate}.lg\:btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);--btn-soft-bg:initial}.lg\:btn-secondary{--btn-color:var(--color-secondary);--btn-fg:var(--color-secondary-content);--btn-soft-bg:initial}.lg\:btn-accent{--btn-color:var(--color-accent);--btn-fg:var(--color-accent-content);--btn-soft-bg:initial}.lg\:btn-neutral{--btn-color:var(--color-neutral);--btn-fg:var(--color-neutral-content);--btn-soft-bg:var(--color-neutral-content) 80%;--btn-rest-fg:initial}.lg\:btn-info{--btn-color:var(--color-info);--btn-fg:var(--color-info-content);--btn-soft-bg:initial}.lg\:btn-success{--btn-color:var(--color-success);--btn-fg:var(--color-success-content);--btn-soft-bg:initial}.lg\:btn-warning{--btn-color:var(--color-warning);--btn-fg:var(--color-warning-content);--btn-soft-bg:initial}.lg\:btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content);--btn-soft-bg:initial}.lg\:btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.lg\:btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.lg\:btn-md{--fontsize:.875rem;--btn-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.lg\:btn-lg{--fontsize:1.125rem;--btn-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.lg\:btn-xl{--fontsize:1.375rem;--btn-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}.lg\:btn-wide{width:100%;max-width:16rem}.lg\:btn-block{width:100%}.lg\:btn-square{width:var(--size);height:var(--size);padding-inline:0}.lg\:btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}}@layer daisyui{.lg\:btn-link{--btn-bg:#0000;color:var(--btn-color,var(--color-primary));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none;text-decoration-line:underline}.lg\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]){pointer-events:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);--btn-bg:#0000;--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.lg\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]):not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{:where(.btn){width:unset}.xl\:btn{--size:calc(var(--size-field,.25rem) * 10);--btn-p:1rem;--btn-fg:var(--color-base-content);cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;-webkit-user-select:none;user-select:none;border-width:var(--border);touch-action:manipulation;--btn-bg:var(--btn-color,var(--color-base-200));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 calc(var(--depth) * 5%));--btn-soft-bg:initial;--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));height:var(--size);padding-inline:var(--btn-p);font-weight:600;font-size:var(--fontsize,.875rem);background-color:var(--btn-bg);color:var(--btn-fg);border-color:var(--btn-border);border-style:var(--btn-border-style,solid);outline-color:var(--btn-color,var(--color-base-content));--tw-prose-links:var(--btn-fg);background-image:none, var(--fx-noise);background-size:auto, calc(var(--noise,0) * 100%);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));box-shadow:var(--btn-inset) inset, var(--btn-shadow);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;transition-property:color,background-color,border-color,box-shadow,transform;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}.xl\:btn:is([type=checkbox],[type=radio]){appearance:none;&[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}}.xl\:btn:where(:checked:not(.filter [type=radio].btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.xl\:btn-outline,.xl\:btn-dash{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:var(--btn-color,var(--color-base-content));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.xl\:btn-dash{--btn-border-style:dashed}.xl\:btn-ghost{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content,currentColor)));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.xl\:btn-soft{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--btn-soft-bg,var(--color-base-100)));color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--btn-soft-bg,var(--color-base-100)));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}}.xl\:prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}@layer daisyui.l1{@media (hover:hover){.xl\:btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);color:var(--btn-fg);--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.xl\:btn:active:not(.btn-active,[aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);translate:0 .5px}.xl\:btn:where(:checked:not(.filter [type=radio].btn),:not([type=radio],[type=checkbox]):focus-visible){--btn-bg:var(--btn-color,var(--color-base-200));color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);isolation:isolate}.xl\:btn:focus-visible,.xl\:btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}}@layer daisyui.l1.l2{.xl\:btn-active,.xl\:btn:is([aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);isolation:isolate}.xl\:btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);--btn-soft-bg:initial}.xl\:btn-secondary{--btn-color:var(--color-secondary);--btn-fg:var(--color-secondary-content);--btn-soft-bg:initial}.xl\:btn-accent{--btn-color:var(--color-accent);--btn-fg:var(--color-accent-content);--btn-soft-bg:initial}.xl\:btn-neutral{--btn-color:var(--color-neutral);--btn-fg:var(--color-neutral-content);--btn-soft-bg:var(--color-neutral-content) 80%;--btn-rest-fg:initial}.xl\:btn-info{--btn-color:var(--color-info);--btn-fg:var(--color-info-content);--btn-soft-bg:initial}.xl\:btn-success{--btn-color:var(--color-success);--btn-fg:var(--color-success-content);--btn-soft-bg:initial}.xl\:btn-warning{--btn-color:var(--color-warning);--btn-fg:var(--color-warning-content);--btn-soft-bg:initial}.xl\:btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content);--btn-soft-bg:initial}.xl\:btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.xl\:btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.xl\:btn-md{--fontsize:.875rem;--btn-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.xl\:btn-lg{--fontsize:1.125rem;--btn-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.xl\:btn-xl{--fontsize:1.375rem;--btn-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}.xl\:btn-wide{width:100%;max-width:16rem}.xl\:btn-block{width:100%}.xl\:btn-square{width:var(--size);height:var(--size);padding-inline:0}.xl\:btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}}@layer daisyui{.xl\:btn-link{--btn-bg:#0000;color:var(--btn-color,var(--color-primary));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none;text-decoration-line:underline}.xl\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]){pointer-events:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);--btn-bg:#0000;--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.xl\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]):not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{:where(.btn){width:unset}.\32 xl\:btn{--size:calc(var(--size-field,.25rem) * 10);--btn-p:1rem;--btn-fg:var(--color-base-content);cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;-webkit-user-select:none;user-select:none;border-width:var(--border);touch-action:manipulation;--btn-bg:var(--btn-color,var(--color-base-200));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 calc(var(--depth) * 5%));--btn-soft-bg:initial;--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));height:var(--size);padding-inline:var(--btn-p);font-weight:600;font-size:var(--fontsize,.875rem);background-color:var(--btn-bg);color:var(--btn-fg);border-color:var(--btn-border);border-style:var(--btn-border-style,solid);outline-color:var(--btn-color,var(--color-base-content));--tw-prose-links:var(--btn-fg);background-image:none, var(--fx-noise);background-size:auto, calc(var(--noise,0) * 100%);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));box-shadow:var(--btn-inset) inset, var(--btn-shadow);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;transition-property:color,background-color,border-color,box-shadow,transform;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}.\32 xl\:btn:is([type=checkbox],[type=radio]){appearance:none;&[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}}.\32 xl\:btn:where(:checked:not(.filter [type=radio].btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.\32 xl\:btn-outline,.\32 xl\:btn-dash{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:var(--btn-color,var(--color-base-content));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.\32 xl\:btn-dash{--btn-border-style:dashed}.\32 xl\:btn-ghost{--btn-bg:#0000;color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content,currentColor)));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.\32 xl\:btn-soft{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 8%, var(--btn-soft-bg,var(--color-base-100)));color:var(--btn-rest-fg,var(--btn-color,var(--color-base-content)));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-content)) 10%, var(--btn-soft-bg,var(--color-base-100)));--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}}.\32 xl\:prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}@layer daisyui.l1{@media (hover:hover){.\32 xl\:btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);color:var(--btn-fg);--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.\32 xl\:btn:active:not(.btn-active,[aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);translate:0 .5px}.\32 xl\:btn:where(:checked:not(.filter [type=radio].btn),:not([type=radio],[type=checkbox]):focus-visible){--btn-bg:var(--btn-color,var(--color-base-200));color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%));--btn-border-style:solid;--btn-inset:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%));--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000);isolation:isolate}.\32 xl\:btn:focus-visible,.\32 xl\:btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}}@layer daisyui.l1.l2{.\32 xl\:btn-active,.\32 xl\:btn:is([aria-pressed=true],[aria-checked=true],[aria-current]:not([aria-current=false],[aria-current=""])){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%);color:var(--btn-fg,var(--color-base-content));--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%);--btn-border-style:solid;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);isolation:isolate}.\32 xl\:btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);--btn-soft-bg:initial}.\32 xl\:btn-secondary{--btn-color:var(--color-secondary);--btn-fg:var(--color-secondary-content);--btn-soft-bg:initial}.\32 xl\:btn-accent{--btn-color:var(--color-accent);--btn-fg:var(--color-accent-content);--btn-soft-bg:initial}.\32 xl\:btn-neutral{--btn-color:var(--color-neutral);--btn-fg:var(--color-neutral-content);--btn-soft-bg:var(--color-neutral-content) 80%;--btn-rest-fg:initial}.\32 xl\:btn-info{--btn-color:var(--color-info);--btn-fg:var(--color-info-content);--btn-soft-bg:initial}.\32 xl\:btn-success{--btn-color:var(--color-success);--btn-fg:var(--color-success-content);--btn-soft-bg:initial}.\32 xl\:btn-warning{--btn-color:var(--color-warning);--btn-fg:var(--color-warning-content);--btn-soft-bg:initial}.\32 xl\:btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content);--btn-soft-bg:initial}.\32 xl\:btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.\32 xl\:btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.\32 xl\:btn-md{--fontsize:.875rem;--btn-p:1rem;--size:calc(var(--size-field,.25rem) * 10)}.\32 xl\:btn-lg{--fontsize:1.125rem;--btn-p:1.25rem;--size:calc(var(--size-field,.25rem) * 12)}.\32 xl\:btn-xl{--fontsize:1.375rem;--btn-p:1.5rem;--size:calc(var(--size-field,.25rem) * 14)}.\32 xl\:btn-wide{width:100%;max-width:16rem}.\32 xl\:btn-block{width:100%}.\32 xl\:btn-square{width:var(--size);height:var(--size);padding-inline:0}.\32 xl\:btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}}@layer daisyui{.\32 xl\:btn-link{--btn-bg:#0000;color:var(--btn-color,var(--color-primary));--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none;text-decoration-line:underline}.\32 xl\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]){pointer-events:none;color:color-mix(in oklch, var(--color-base-content) 20%, #0000);--btn-bg:#0000;--btn-border:#0000;--btn-inset:0 0 0 0 oklch(0% 0 0/0);--btn-shadow:0 0 0 0 oklch(0% 0 0/0);background-image:none}.\32 xl\:btn:is(.btn-disabled,:disabled,[disabled],[aria-disabled=true]):not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:link{cursor:pointer;text-decoration-line:underline}.sm\:link:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.sm\:link:focus-visible{outline-offset:2px;outline:2px solid}}@layer daisyui.l1.l2{.sm\:link-hover{text-decoration-line:none}@media (hover:hover){.sm\:link-hover:hover{text-decoration-line:underline}}.sm\:link-primary{color:var(--color-primary)}@media (hover:hover){.sm\:link-primary:hover{color:color-mix(in oklab, var(--color-primary) 80%, #000)}}.sm\:link-secondary{color:var(--color-secondary)}@media (hover:hover){.sm\:link-secondary:hover{color:color-mix(in oklab, var(--color-secondary) 80%, #000)}}.sm\:link-accent{color:var(--color-accent)}@media (hover:hover){.sm\:link-accent:hover{color:color-mix(in oklab, var(--color-accent) 80%, #000)}}.sm\:link-neutral{color:var(--color-neutral)}@media (hover:hover){.sm\:link-neutral:hover{color:color-mix(in oklab, var(--color-neutral) 80%, #000)}}.sm\:link-success{color:var(--color-success)}@media (hover:hover){.sm\:link-success:hover{color:color-mix(in oklab, var(--color-success) 80%, #000)}}.sm\:link-info{color:var(--color-info)}@media (hover:hover){.sm\:link-info:hover{color:color-mix(in oklab, var(--color-info) 80%, #000)}}.sm\:link-warning{color:var(--color-warning)}@media (hover:hover){.sm\:link-warning:hover{color:color-mix(in oklab, var(--color-warning) 80%, #000)}}.sm\:link-error{color:var(--color-error)}@media (hover:hover){.sm\:link-error:hover{color:color-mix(in oklab, var(--color-error) 80%, #000)}}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:link{cursor:pointer;text-decoration-line:underline}.md\:link:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.md\:link:focus-visible{outline-offset:2px;outline:2px solid}}@layer daisyui.l1.l2{.md\:link-hover{text-decoration-line:none}@media (hover:hover){.md\:link-hover:hover{text-decoration-line:underline}}.md\:link-primary{color:var(--color-primary)}@media (hover:hover){.md\:link-primary:hover{color:color-mix(in oklab, var(--color-primary) 80%, #000)}}.md\:link-secondary{color:var(--color-secondary)}@media (hover:hover){.md\:link-secondary:hover{color:color-mix(in oklab, var(--color-secondary) 80%, #000)}}.md\:link-accent{color:var(--color-accent)}@media (hover:hover){.md\:link-accent:hover{color:color-mix(in oklab, var(--color-accent) 80%, #000)}}.md\:link-neutral{color:var(--color-neutral)}@media (hover:hover){.md\:link-neutral:hover{color:color-mix(in oklab, var(--color-neutral) 80%, #000)}}.md\:link-success{color:var(--color-success)}@media (hover:hover){.md\:link-success:hover{color:color-mix(in oklab, var(--color-success) 80%, #000)}}.md\:link-info{color:var(--color-info)}@media (hover:hover){.md\:link-info:hover{color:color-mix(in oklab, var(--color-info) 80%, #000)}}.md\:link-warning{color:var(--color-warning)}@media (hover:hover){.md\:link-warning:hover{color:color-mix(in oklab, var(--color-warning) 80%, #000)}}.md\:link-error{color:var(--color-error)}@media (hover:hover){.md\:link-error:hover{color:color-mix(in oklab, var(--color-error) 80%, #000)}}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:link{cursor:pointer;text-decoration-line:underline}.lg\:link:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.lg\:link:focus-visible{outline-offset:2px;outline:2px solid}}@layer daisyui.l1.l2{.lg\:link-hover{text-decoration-line:none}@media (hover:hover){.lg\:link-hover:hover{text-decoration-line:underline}}.lg\:link-primary{color:var(--color-primary)}@media (hover:hover){.lg\:link-primary:hover{color:color-mix(in oklab, var(--color-primary) 80%, #000)}}.lg\:link-secondary{color:var(--color-secondary)}@media (hover:hover){.lg\:link-secondary:hover{color:color-mix(in oklab, var(--color-secondary) 80%, #000)}}.lg\:link-accent{color:var(--color-accent)}@media (hover:hover){.lg\:link-accent:hover{color:color-mix(in oklab, var(--color-accent) 80%, #000)}}.lg\:link-neutral{color:var(--color-neutral)}@media (hover:hover){.lg\:link-neutral:hover{color:color-mix(in oklab, var(--color-neutral) 80%, #000)}}.lg\:link-success{color:var(--color-success)}@media (hover:hover){.lg\:link-success:hover{color:color-mix(in oklab, var(--color-success) 80%, #000)}}.lg\:link-info{color:var(--color-info)}@media (hover:hover){.lg\:link-info:hover{color:color-mix(in oklab, var(--color-info) 80%, #000)}}.lg\:link-warning{color:var(--color-warning)}@media (hover:hover){.lg\:link-warning:hover{color:color-mix(in oklab, var(--color-warning) 80%, #000)}}.lg\:link-error{color:var(--color-error)}@media (hover:hover){.lg\:link-error:hover{color:color-mix(in oklab, var(--color-error) 80%, #000)}}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:link{cursor:pointer;text-decoration-line:underline}.xl\:link:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.xl\:link:focus-visible{outline-offset:2px;outline:2px solid}}@layer daisyui.l1.l2{.xl\:link-hover{text-decoration-line:none}@media (hover:hover){.xl\:link-hover:hover{text-decoration-line:underline}}.xl\:link-primary{color:var(--color-primary)}@media (hover:hover){.xl\:link-primary:hover{color:color-mix(in oklab, var(--color-primary) 80%, #000)}}.xl\:link-secondary{color:var(--color-secondary)}@media (hover:hover){.xl\:link-secondary:hover{color:color-mix(in oklab, var(--color-secondary) 80%, #000)}}.xl\:link-accent{color:var(--color-accent)}@media (hover:hover){.xl\:link-accent:hover{color:color-mix(in oklab, var(--color-accent) 80%, #000)}}.xl\:link-neutral{color:var(--color-neutral)}@media (hover:hover){.xl\:link-neutral:hover{color:color-mix(in oklab, var(--color-neutral) 80%, #000)}}.xl\:link-success{color:var(--color-success)}@media (hover:hover){.xl\:link-success:hover{color:color-mix(in oklab, var(--color-success) 80%, #000)}}.xl\:link-info{color:var(--color-info)}@media (hover:hover){.xl\:link-info:hover{color:color-mix(in oklab, var(--color-info) 80%, #000)}}.xl\:link-warning{color:var(--color-warning)}@media (hover:hover){.xl\:link-warning:hover{color:color-mix(in oklab, var(--color-warning) 80%, #000)}}.xl\:link-error{color:var(--color-error)}@media (hover:hover){.xl\:link-error:hover{color:color-mix(in oklab, var(--color-error) 80%, #000)}}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:link{cursor:pointer;text-decoration-line:underline}.\32 xl\:link:focus{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.\32 xl\:link:focus-visible{outline-offset:2px;outline:2px solid}}@layer daisyui.l1.l2{.\32 xl\:link-hover{text-decoration-line:none}@media (hover:hover){.\32 xl\:link-hover:hover{text-decoration-line:underline}}.\32 xl\:link-primary{color:var(--color-primary)}@media (hover:hover){.\32 xl\:link-primary:hover{color:color-mix(in oklab, var(--color-primary) 80%, #000)}}.\32 xl\:link-secondary{color:var(--color-secondary)}@media (hover:hover){.\32 xl\:link-secondary:hover{color:color-mix(in oklab, var(--color-secondary) 80%, #000)}}.\32 xl\:link-accent{color:var(--color-accent)}@media (hover:hover){.\32 xl\:link-accent:hover{color:color-mix(in oklab, var(--color-accent) 80%, #000)}}.\32 xl\:link-neutral{color:var(--color-neutral)}@media (hover:hover){.\32 xl\:link-neutral:hover{color:color-mix(in oklab, var(--color-neutral) 80%, #000)}}.\32 xl\:link-success{color:var(--color-success)}@media (hover:hover){.\32 xl\:link-success:hover{color:color-mix(in oklab, var(--color-success) 80%, #000)}}.\32 xl\:link-info{color:var(--color-info)}@media (hover:hover){.\32 xl\:link-info:hover{color:color-mix(in oklab, var(--color-info) 80%, #000)}}.\32 xl\:link-warning{color:var(--color-warning)}@media (hover:hover){.\32 xl\:link-warning:hover{color:color-mix(in oklab, var(--color-warning) 80%, #000)}}.\32 xl\:link-error{color:var(--color-error)}@media (hover:hover){.\32 xl\:link-error:hover{color:color-mix(in oklab, var(--color-error) 80%, #000)}}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:dock{z-index:1;background-color:var(--color-base-100);color:currentColor;border-top:.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);width:100%;height:4rem;height:calc(4rem + env(safe-area-inset-bottom));padding:.5rem;padding-bottom:env(safe-area-inset-bottom);flex-direction:row;justify-content:space-around;align-items:center;display:flex;position:fixed;bottom:0;left:0;right:0}.sm\:dock>:not(:where(script,style,template)){cursor:pointer;border-radius:var(--radius-box);background-color:#0000;flex-direction:column;flex-shrink:1;flex-basis:100%;justify-content:center;align-items:center;gap:1px;max-width:8rem;height:100%;margin-bottom:.5rem;transition:opacity .2s ease-out;display:flex;position:relative;@media (hover:hover){&:hover{opacity:.8}}&[aria-disabled=true],&[disabled]{&,&:hover{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 10%, transparent);opacity:1}}& .dock-label{font-size:.6875rem}&:after{content:"";background-color:#0000;border-top:3px solid #0000;border-radius:3.40282e38px;width:1.5rem;height:.25rem;transition:background-color .1s ease-out,text-color .1s ease-out,width .1s ease-out;position:absolute;bottom:.2rem}}}@layer daisyui.l1.l2{.sm\:dock>[aria-current]:not([aria-current=false],[aria-current=""]):after,.sm\:dock-active:after{color:currentColor;background-color:currentColor;width:2.5rem}.sm\:dock-xs{height:3rem;height:calc(3rem + env(safe-area-inset-bottom))}.sm\:dock-xs :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.sm\:dock-xs .dock-label{font-size:.625rem}.sm\:dock-sm{height:3.5rem;height:calc(3.5rem + env(safe-area-inset-bottom))}.sm\:dock-sm :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.sm\:dock-sm .dock-label{font-size:.625rem}.sm\:dock-md{height:4rem;height:calc(4rem + env(safe-area-inset-bottom))}.sm\:dock-md .dock-label{font-size:.6875rem}.sm\:dock-lg{height:4.5rem;height:calc(4.5rem + env(safe-area-inset-bottom))}.sm\:dock-lg :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.sm\:dock-lg .dock-label{font-size:.6875rem}.sm\:dock-xl{height:5rem;height:calc(5rem + env(safe-area-inset-bottom))}.sm\:dock-xl :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.sm\:dock-xl .dock-label{font-size:.75rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:dock{z-index:1;background-color:var(--color-base-100);color:currentColor;border-top:.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);width:100%;height:4rem;height:calc(4rem + env(safe-area-inset-bottom));padding:.5rem;padding-bottom:env(safe-area-inset-bottom);flex-direction:row;justify-content:space-around;align-items:center;display:flex;position:fixed;bottom:0;left:0;right:0}.md\:dock>:not(:where(script,style,template)){cursor:pointer;border-radius:var(--radius-box);background-color:#0000;flex-direction:column;flex-shrink:1;flex-basis:100%;justify-content:center;align-items:center;gap:1px;max-width:8rem;height:100%;margin-bottom:.5rem;transition:opacity .2s ease-out;display:flex;position:relative;@media (hover:hover){&:hover{opacity:.8}}&[aria-disabled=true],&[disabled]{&,&:hover{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 10%, transparent);opacity:1}}& .dock-label{font-size:.6875rem}&:after{content:"";background-color:#0000;border-top:3px solid #0000;border-radius:3.40282e38px;width:1.5rem;height:.25rem;transition:background-color .1s ease-out,text-color .1s ease-out,width .1s ease-out;position:absolute;bottom:.2rem}}}@layer daisyui.l1.l2{.md\:dock>[aria-current]:not([aria-current=false],[aria-current=""]):after,.md\:dock-active:after{color:currentColor;background-color:currentColor;width:2.5rem}.md\:dock-xs{height:3rem;height:calc(3rem + env(safe-area-inset-bottom))}.md\:dock-xs :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.md\:dock-xs .dock-label{font-size:.625rem}.md\:dock-sm{height:3.5rem;height:calc(3.5rem + env(safe-area-inset-bottom))}.md\:dock-sm :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.md\:dock-sm .dock-label{font-size:.625rem}.md\:dock-md{height:4rem;height:calc(4rem + env(safe-area-inset-bottom))}.md\:dock-md .dock-label{font-size:.6875rem}.md\:dock-lg{height:4.5rem;height:calc(4.5rem + env(safe-area-inset-bottom))}.md\:dock-lg :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.md\:dock-lg .dock-label{font-size:.6875rem}.md\:dock-xl{height:5rem;height:calc(5rem + env(safe-area-inset-bottom))}.md\:dock-xl :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.md\:dock-xl .dock-label{font-size:.75rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:dock{z-index:1;background-color:var(--color-base-100);color:currentColor;border-top:.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);width:100%;height:4rem;height:calc(4rem + env(safe-area-inset-bottom));padding:.5rem;padding-bottom:env(safe-area-inset-bottom);flex-direction:row;justify-content:space-around;align-items:center;display:flex;position:fixed;bottom:0;left:0;right:0}.lg\:dock>:not(:where(script,style,template)){cursor:pointer;border-radius:var(--radius-box);background-color:#0000;flex-direction:column;flex-shrink:1;flex-basis:100%;justify-content:center;align-items:center;gap:1px;max-width:8rem;height:100%;margin-bottom:.5rem;transition:opacity .2s ease-out;display:flex;position:relative;@media (hover:hover){&:hover{opacity:.8}}&[aria-disabled=true],&[disabled]{&,&:hover{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 10%, transparent);opacity:1}}& .dock-label{font-size:.6875rem}&:after{content:"";background-color:#0000;border-top:3px solid #0000;border-radius:3.40282e38px;width:1.5rem;height:.25rem;transition:background-color .1s ease-out,text-color .1s ease-out,width .1s ease-out;position:absolute;bottom:.2rem}}}@layer daisyui.l1.l2{.lg\:dock>[aria-current]:not([aria-current=false],[aria-current=""]):after,.lg\:dock-active:after{color:currentColor;background-color:currentColor;width:2.5rem}.lg\:dock-xs{height:3rem;height:calc(3rem + env(safe-area-inset-bottom))}.lg\:dock-xs :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.lg\:dock-xs .dock-label{font-size:.625rem}.lg\:dock-sm{height:3.5rem;height:calc(3.5rem + env(safe-area-inset-bottom))}.lg\:dock-sm :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.lg\:dock-sm .dock-label{font-size:.625rem}.lg\:dock-md{height:4rem;height:calc(4rem + env(safe-area-inset-bottom))}.lg\:dock-md .dock-label{font-size:.6875rem}.lg\:dock-lg{height:4.5rem;height:calc(4.5rem + env(safe-area-inset-bottom))}.lg\:dock-lg :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.lg\:dock-lg .dock-label{font-size:.6875rem}.lg\:dock-xl{height:5rem;height:calc(5rem + env(safe-area-inset-bottom))}.lg\:dock-xl :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.lg\:dock-xl .dock-label{font-size:.75rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:dock{z-index:1;background-color:var(--color-base-100);color:currentColor;border-top:.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);width:100%;height:4rem;height:calc(4rem + env(safe-area-inset-bottom));padding:.5rem;padding-bottom:env(safe-area-inset-bottom);flex-direction:row;justify-content:space-around;align-items:center;display:flex;position:fixed;bottom:0;left:0;right:0}.xl\:dock>:not(:where(script,style,template)){cursor:pointer;border-radius:var(--radius-box);background-color:#0000;flex-direction:column;flex-shrink:1;flex-basis:100%;justify-content:center;align-items:center;gap:1px;max-width:8rem;height:100%;margin-bottom:.5rem;transition:opacity .2s ease-out;display:flex;position:relative;@media (hover:hover){&:hover{opacity:.8}}&[aria-disabled=true],&[disabled]{&,&:hover{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 10%, transparent);opacity:1}}& .dock-label{font-size:.6875rem}&:after{content:"";background-color:#0000;border-top:3px solid #0000;border-radius:3.40282e38px;width:1.5rem;height:.25rem;transition:background-color .1s ease-out,text-color .1s ease-out,width .1s ease-out;position:absolute;bottom:.2rem}}}@layer daisyui.l1.l2{.xl\:dock>[aria-current]:not([aria-current=false],[aria-current=""]):after,.xl\:dock-active:after{color:currentColor;background-color:currentColor;width:2.5rem}.xl\:dock-xs{height:3rem;height:calc(3rem + env(safe-area-inset-bottom))}.xl\:dock-xs :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.xl\:dock-xs .dock-label{font-size:.625rem}.xl\:dock-sm{height:3.5rem;height:calc(3.5rem + env(safe-area-inset-bottom))}.xl\:dock-sm :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.xl\:dock-sm .dock-label{font-size:.625rem}.xl\:dock-md{height:4rem;height:calc(4rem + env(safe-area-inset-bottom))}.xl\:dock-md .dock-label{font-size:.6875rem}.xl\:dock-lg{height:4.5rem;height:calc(4.5rem + env(safe-area-inset-bottom))}.xl\:dock-lg :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.xl\:dock-lg .dock-label{font-size:.6875rem}.xl\:dock-xl{height:5rem;height:calc(5rem + env(safe-area-inset-bottom))}.xl\:dock-xl :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.xl\:dock-xl .dock-label{font-size:.75rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:dock{z-index:1;background-color:var(--color-base-100);color:currentColor;border-top:.5px solid color-mix(in oklab, var(--color-base-content) 5%, #0000);width:100%;height:4rem;height:calc(4rem + env(safe-area-inset-bottom));padding:.5rem;padding-bottom:env(safe-area-inset-bottom);flex-direction:row;justify-content:space-around;align-items:center;display:flex;position:fixed;bottom:0;left:0;right:0}.\32 xl\:dock>:not(:where(script,style,template)){cursor:pointer;border-radius:var(--radius-box);background-color:#0000;flex-direction:column;flex-shrink:1;flex-basis:100%;justify-content:center;align-items:center;gap:1px;max-width:8rem;height:100%;margin-bottom:.5rem;transition:opacity .2s ease-out;display:flex;position:relative;@media (hover:hover){&:hover{opacity:.8}}&[aria-disabled=true],&[disabled]{&,&:hover{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 10%, transparent);opacity:1}}& .dock-label{font-size:.6875rem}&:after{content:"";background-color:#0000;border-top:3px solid #0000;border-radius:3.40282e38px;width:1.5rem;height:.25rem;transition:background-color .1s ease-out,text-color .1s ease-out,width .1s ease-out;position:absolute;bottom:.2rem}}}@layer daisyui.l1.l2{.\32 xl\:dock>[aria-current]:not([aria-current=false],[aria-current=""]):after,.\32 xl\:dock-active:after{color:currentColor;background-color:currentColor;width:2.5rem}.\32 xl\:dock-xs{height:3rem;height:calc(3rem + env(safe-area-inset-bottom))}.\32 xl\:dock-xs :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.\32 xl\:dock-xs .dock-label{font-size:.625rem}.\32 xl\:dock-sm{height:3.5rem;height:calc(3.5rem + env(safe-area-inset-bottom))}.\32 xl\:dock-sm :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:-.1rem}.\32 xl\:dock-sm .dock-label{font-size:.625rem}.\32 xl\:dock-md{height:4rem;height:calc(4rem + env(safe-area-inset-bottom))}.\32 xl\:dock-md .dock-label{font-size:.6875rem}.\32 xl\:dock-lg{height:4.5rem;height:calc(4.5rem + env(safe-area-inset-bottom))}.\32 xl\:dock-lg :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.\32 xl\:dock-lg .dock-label{font-size:.6875rem}.\32 xl\:dock-xl{height:5rem;height:calc(5rem + env(safe-area-inset-bottom))}.\32 xl\:dock-xl :is(.dock-active,[aria-current]:not([aria-current=false],[aria-current=""])):after{bottom:.4rem}.\32 xl\:dock-xl .dock-label{font-size:.75rem}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000));box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}.sm\:radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.sm\:radio:focus-visible{outline:2px solid}.sm\:radio:checked,.sm\:radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor;@media (prefers-reduced-motion:no-preference){animation:.2s ease-out radio}&:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){&:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{&:before{outline-offset:-1rem;outline:.25rem solid}}}}@layer daisyui.l1.l2{.sm\:radio-primary{--input-color:var(--color-primary)}.sm\:radio-secondary{--input-color:var(--color-secondary)}.sm\:radio-accent{--input-color:var(--color-accent)}.sm\:radio-neutral{--input-color:var(--color-neutral)}.sm\:radio-info{--input-color:var(--color-info)}.sm\:radio-success{--input-color:var(--color-success)}.sm\:radio-warning{--input-color:var(--color-warning)}.sm\:radio-error{--input-color:var(--color-error)}.sm\:radio:disabled{cursor:not-allowed;opacity:.2}.sm\:radio-xs{padding:.125rem}.sm\:radio-xs[type=radio]{--size:calc(var(--size-selector,.25rem) * 4)}.sm\:radio-sm{padding:.1875rem}.sm\:radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)}.sm\:radio-md{padding:.25rem}.sm\:radio-md[type=radio]{--size:calc(var(--size-selector,.25rem) * 6)}.sm\:radio-lg{padding:.3125rem}.sm\:radio-lg[type=radio]{--size:calc(var(--size-selector,.25rem) * 7)}.sm\:radio-xl{padding:.375rem}.sm\:radio-xl[type=radio]{--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000));box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}.md\:radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.md\:radio:focus-visible{outline:2px solid}.md\:radio:checked,.md\:radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor;@media (prefers-reduced-motion:no-preference){animation:.2s ease-out radio}&:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){&:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{&:before{outline-offset:-1rem;outline:.25rem solid}}}}@layer daisyui.l1.l2{.md\:radio-primary{--input-color:var(--color-primary)}.md\:radio-secondary{--input-color:var(--color-secondary)}.md\:radio-accent{--input-color:var(--color-accent)}.md\:radio-neutral{--input-color:var(--color-neutral)}.md\:radio-info{--input-color:var(--color-info)}.md\:radio-success{--input-color:var(--color-success)}.md\:radio-warning{--input-color:var(--color-warning)}.md\:radio-error{--input-color:var(--color-error)}.md\:radio:disabled{cursor:not-allowed;opacity:.2}.md\:radio-xs{padding:.125rem}.md\:radio-xs[type=radio]{--size:calc(var(--size-selector,.25rem) * 4)}.md\:radio-sm{padding:.1875rem}.md\:radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)}.md\:radio-md{padding:.25rem}.md\:radio-md[type=radio]{--size:calc(var(--size-selector,.25rem) * 6)}.md\:radio-lg{padding:.3125rem}.md\:radio-lg[type=radio]{--size:calc(var(--size-selector,.25rem) * 7)}.md\:radio-xl{padding:.375rem}.md\:radio-xl[type=radio]{--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000));box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}.lg\:radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.lg\:radio:focus-visible{outline:2px solid}.lg\:radio:checked,.lg\:radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor;@media (prefers-reduced-motion:no-preference){animation:.2s ease-out radio}&:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){&:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{&:before{outline-offset:-1rem;outline:.25rem solid}}}}@layer daisyui.l1.l2{.lg\:radio-primary{--input-color:var(--color-primary)}.lg\:radio-secondary{--input-color:var(--color-secondary)}.lg\:radio-accent{--input-color:var(--color-accent)}.lg\:radio-neutral{--input-color:var(--color-neutral)}.lg\:radio-info{--input-color:var(--color-info)}.lg\:radio-success{--input-color:var(--color-success)}.lg\:radio-warning{--input-color:var(--color-warning)}.lg\:radio-error{--input-color:var(--color-error)}.lg\:radio:disabled{cursor:not-allowed;opacity:.2}.lg\:radio-xs{padding:.125rem}.lg\:radio-xs[type=radio]{--size:calc(var(--size-selector,.25rem) * 4)}.lg\:radio-sm{padding:.1875rem}.lg\:radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)}.lg\:radio-md{padding:.25rem}.lg\:radio-md[type=radio]{--size:calc(var(--size-selector,.25rem) * 6)}.lg\:radio-lg{padding:.3125rem}.lg\:radio-lg[type=radio]{--size:calc(var(--size-selector,.25rem) * 7)}.lg\:radio-xl{padding:.375rem}.lg\:radio-xl[type=radio]{--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000));box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}.xl\:radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.xl\:radio:focus-visible{outline:2px solid}.xl\:radio:checked,.xl\:radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor;@media (prefers-reduced-motion:no-preference){animation:.2s ease-out radio}&:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){&:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{&:before{outline-offset:-1rem;outline:.25rem solid}}}}@layer daisyui.l1.l2{.xl\:radio-primary{--input-color:var(--color-primary)}.xl\:radio-secondary{--input-color:var(--color-secondary)}.xl\:radio-accent{--input-color:var(--color-accent)}.xl\:radio-neutral{--input-color:var(--color-neutral)}.xl\:radio-info{--input-color:var(--color-info)}.xl\:radio-success{--input-color:var(--color-success)}.xl\:radio-warning{--input-color:var(--color-warning)}.xl\:radio-error{--input-color:var(--color-error)}.xl\:radio:disabled{cursor:not-allowed;opacity:.2}.xl\:radio-xs{padding:.125rem}.xl\:radio-xs[type=radio]{--size:calc(var(--size-selector,.25rem) * 4)}.xl\:radio-sm{padding:.1875rem}.xl\:radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)}.xl\:radio-md{padding:.25rem}.xl\:radio-md[type=radio]{--size:calc(var(--size-selector,.25rem) * 6)}.xl\:radio-lg{padding:.3125rem}.xl\:radio-lg[type=radio]{--size:calc(var(--size-selector,.25rem) * 7)}.xl\:radio-xl{padding:.375rem}.xl\:radio-xl[type=radio]{--size:calc(var(--size-selector,.25rem) * 8)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000));box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}.\32 xl\:radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.\32 xl\:radio:focus-visible{outline:2px solid}.\32 xl\:radio:checked,.\32 xl\:radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor;@media (prefers-reduced-motion:no-preference){animation:.2s ease-out radio}&:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){&:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{&:before{outline-offset:-1rem;outline:.25rem solid}}}}@layer daisyui.l1.l2{.\32 xl\:radio-primary{--input-color:var(--color-primary)}.\32 xl\:radio-secondary{--input-color:var(--color-secondary)}.\32 xl\:radio-accent{--input-color:var(--color-accent)}.\32 xl\:radio-neutral{--input-color:var(--color-neutral)}.\32 xl\:radio-info{--input-color:var(--color-info)}.\32 xl\:radio-success{--input-color:var(--color-success)}.\32 xl\:radio-warning{--input-color:var(--color-warning)}.\32 xl\:radio-error{--input-color:var(--color-error)}.\32 xl\:radio:disabled{cursor:not-allowed;opacity:.2}.\32 xl\:radio-xs{padding:.125rem}.\32 xl\:radio-xs[type=radio]{--size:calc(var(--size-selector,.25rem) * 4)}.\32 xl\:radio-sm{padding:.1875rem}.\32 xl\:radio-sm[type=radio]{--size:calc(var(--size-selector,.25rem) * 5)}.\32 xl\:radio-md{padding:.25rem}.\32 xl\:radio-md[type=radio]{--size:calc(var(--size-selector,.25rem) * 6)}.\32 xl\:radio-lg{padding:.3125rem}.\32 xl\:radio-lg[type=radio]{--size:calc(var(--size-selector,.25rem) * 7)}.\32 xl\:radio-xl{padding:.375rem}.\32 xl\:radio-xl[type=radio]{--size:calc(var(--size-selector,.25rem) * 8)}}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:progress{appearance:none;border-radius:var(--radius-box);background-color:color-mix(in oklab, currentcolor 20%, transparent);width:100%;height:.5rem;color:var(--color-base-content);position:relative;overflow:hidden}.sm\:progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;@media (prefers-reduced-motion:no-preference){animation:5s ease-in-out infinite progress}@supports ((-moz-appearance:none)){&::-moz-progress-bar{background-color:#0000;@media (prefers-reduced-motion:no-preference){background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}}}@supports ((-moz-appearance:none)){.sm\:progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}@supports ((-webkit-appearance:none)){.sm\:progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.sm\:progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}}@layer daisyui.l1.l2{.sm\:progress-primary{color:var(--color-primary)}.sm\:progress-secondary{color:var(--color-secondary)}.sm\:progress-accent{color:var(--color-accent)}.sm\:progress-neutral{color:var(--color-neutral)}.sm\:progress-info{color:var(--color-info)}.sm\:progress-success{color:var(--color-success)}.sm\:progress-warning{color:var(--color-warning)}.sm\:progress-error{color:var(--color-error)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:progress{appearance:none;border-radius:var(--radius-box);background-color:color-mix(in oklab, currentcolor 20%, transparent);width:100%;height:.5rem;color:var(--color-base-content);position:relative;overflow:hidden}.md\:progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;@media (prefers-reduced-motion:no-preference){animation:5s ease-in-out infinite progress}@supports ((-moz-appearance:none)){&::-moz-progress-bar{background-color:#0000;@media (prefers-reduced-motion:no-preference){background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}}}@supports ((-moz-appearance:none)){.md\:progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}@supports ((-webkit-appearance:none)){.md\:progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.md\:progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}}@layer daisyui.l1.l2{.md\:progress-primary{color:var(--color-primary)}.md\:progress-secondary{color:var(--color-secondary)}.md\:progress-accent{color:var(--color-accent)}.md\:progress-neutral{color:var(--color-neutral)}.md\:progress-info{color:var(--color-info)}.md\:progress-success{color:var(--color-success)}.md\:progress-warning{color:var(--color-warning)}.md\:progress-error{color:var(--color-error)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:progress{appearance:none;border-radius:var(--radius-box);background-color:color-mix(in oklab, currentcolor 20%, transparent);width:100%;height:.5rem;color:var(--color-base-content);position:relative;overflow:hidden}.lg\:progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;@media (prefers-reduced-motion:no-preference){animation:5s ease-in-out infinite progress}@supports ((-moz-appearance:none)){&::-moz-progress-bar{background-color:#0000;@media (prefers-reduced-motion:no-preference){background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}}}@supports ((-moz-appearance:none)){.lg\:progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}@supports ((-webkit-appearance:none)){.lg\:progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.lg\:progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}}@layer daisyui.l1.l2{.lg\:progress-primary{color:var(--color-primary)}.lg\:progress-secondary{color:var(--color-secondary)}.lg\:progress-accent{color:var(--color-accent)}.lg\:progress-neutral{color:var(--color-neutral)}.lg\:progress-info{color:var(--color-info)}.lg\:progress-success{color:var(--color-success)}.lg\:progress-warning{color:var(--color-warning)}.lg\:progress-error{color:var(--color-error)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:progress{appearance:none;border-radius:var(--radius-box);background-color:color-mix(in oklab, currentcolor 20%, transparent);width:100%;height:.5rem;color:var(--color-base-content);position:relative;overflow:hidden}.xl\:progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;@media (prefers-reduced-motion:no-preference){animation:5s ease-in-out infinite progress}@supports ((-moz-appearance:none)){&::-moz-progress-bar{background-color:#0000;@media (prefers-reduced-motion:no-preference){background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}}}@supports ((-moz-appearance:none)){.xl\:progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}@supports ((-webkit-appearance:none)){.xl\:progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.xl\:progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}}@layer daisyui.l1.l2{.xl\:progress-primary{color:var(--color-primary)}.xl\:progress-secondary{color:var(--color-secondary)}.xl\:progress-accent{color:var(--color-accent)}.xl\:progress-neutral{color:var(--color-neutral)}.xl\:progress-info{color:var(--color-info)}.xl\:progress-success{color:var(--color-success)}.xl\:progress-warning{color:var(--color-warning)}.xl\:progress-error{color:var(--color-error)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:progress{appearance:none;border-radius:var(--radius-box);background-color:color-mix(in oklab, currentcolor 20%, transparent);width:100%;height:.5rem;color:var(--color-base-content);position:relative;overflow:hidden}.\32 xl\:progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;@media (prefers-reduced-motion:no-preference){animation:5s ease-in-out infinite progress}@supports ((-moz-appearance:none)){&::-moz-progress-bar{background-color:#0000;@media (prefers-reduced-motion:no-preference){background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}}}@supports ((-moz-appearance:none)){.\32 xl\:progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}@supports ((-webkit-appearance:none)){.\32 xl\:progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.\32 xl\:progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor;@media (prefers-reduced-motion:no-preference){transition:inline-size .3s}}}}@layer daisyui.l1.l2{.\32 xl\:progress-primary{color:var(--color-primary)}.\32 xl\:progress-secondary{color:var(--color-secondary)}.\32 xl\:progress-accent{color:var(--color-accent)}.\32 xl\:progress-neutral{color:var(--color-neutral)}.\32 xl\:progress-info{color:var(--color-info)}.\32 xl\:progress-success{color:var(--color-success)}.\32 xl\:progress-warning{color:var(--color-warning)}.\32 xl\:progress-error{color:var(--color-error)}}}@keyframes progress{50%{background-position-x:-115%}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:radial-progress{height:var(--size);width:var(--size);vertical-align:middle;box-sizing:content-box;--value:0;--size:5rem;--thickness:calc(var(--size) / 10);--radialprogress:calc(var(--value) * 1%);background-color:#0000;border-radius:3.40282e38px;flex-shrink:0;place-content:center;transition:--radialprogress .3s linear;display:inline-grid;position:relative}.sm\:radial-progress:before{content:"";background:radial-gradient(farthest-side, currentColor 98%, #0000) top/var(--thickness) var(--thickness) no-repeat, conic-gradient(currentColor var(--radialprogress), #0000 0);-webkit-mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));border-radius:3.40282e38px;position:absolute;inset:0}.sm\:radial-progress:after{content:"";inset:calc(50% - var(--thickness) / 2);transform:rotate(calc(var(--value) * 3.6deg - 90deg)) translate(calc(var(--size) / 2 - 50%));background-color:currentColor;border-radius:3.40282e38px;transition:transform .3s linear;position:absolute}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:radial-progress{height:var(--size);width:var(--size);vertical-align:middle;box-sizing:content-box;--value:0;--size:5rem;--thickness:calc(var(--size) / 10);--radialprogress:calc(var(--value) * 1%);background-color:#0000;border-radius:3.40282e38px;flex-shrink:0;place-content:center;transition:--radialprogress .3s linear;display:inline-grid;position:relative}.md\:radial-progress:before{content:"";background:radial-gradient(farthest-side, currentColor 98%, #0000) top/var(--thickness) var(--thickness) no-repeat, conic-gradient(currentColor var(--radialprogress), #0000 0);-webkit-mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));border-radius:3.40282e38px;position:absolute;inset:0}.md\:radial-progress:after{content:"";inset:calc(50% - var(--thickness) / 2);transform:rotate(calc(var(--value) * 3.6deg - 90deg)) translate(calc(var(--size) / 2 - 50%));background-color:currentColor;border-radius:3.40282e38px;transition:transform .3s linear;position:absolute}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:radial-progress{height:var(--size);width:var(--size);vertical-align:middle;box-sizing:content-box;--value:0;--size:5rem;--thickness:calc(var(--size) / 10);--radialprogress:calc(var(--value) * 1%);background-color:#0000;border-radius:3.40282e38px;flex-shrink:0;place-content:center;transition:--radialprogress .3s linear;display:inline-grid;position:relative}.lg\:radial-progress:before{content:"";background:radial-gradient(farthest-side, currentColor 98%, #0000) top/var(--thickness) var(--thickness) no-repeat, conic-gradient(currentColor var(--radialprogress), #0000 0);-webkit-mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));border-radius:3.40282e38px;position:absolute;inset:0}.lg\:radial-progress:after{content:"";inset:calc(50% - var(--thickness) / 2);transform:rotate(calc(var(--value) * 3.6deg - 90deg)) translate(calc(var(--size) / 2 - 50%));background-color:currentColor;border-radius:3.40282e38px;transition:transform .3s linear;position:absolute}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:radial-progress{height:var(--size);width:var(--size);vertical-align:middle;box-sizing:content-box;--value:0;--size:5rem;--thickness:calc(var(--size) / 10);--radialprogress:calc(var(--value) * 1%);background-color:#0000;border-radius:3.40282e38px;flex-shrink:0;place-content:center;transition:--radialprogress .3s linear;display:inline-grid;position:relative}.xl\:radial-progress:before{content:"";background:radial-gradient(farthest-side, currentColor 98%, #0000) top/var(--thickness) var(--thickness) no-repeat, conic-gradient(currentColor var(--radialprogress), #0000 0);-webkit-mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));border-radius:3.40282e38px;position:absolute;inset:0}.xl\:radial-progress:after{content:"";inset:calc(50% - var(--thickness) / 2);transform:rotate(calc(var(--value) * 3.6deg - 90deg)) translate(calc(var(--size) / 2 - 50%));background-color:currentColor;border-radius:3.40282e38px;transition:transform .3s linear;position:absolute}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:radial-progress{height:var(--size);width:var(--size);vertical-align:middle;box-sizing:content-box;--value:0;--size:5rem;--thickness:calc(var(--size) / 10);--radialprogress:calc(var(--value) * 1%);background-color:#0000;border-radius:3.40282e38px;flex-shrink:0;place-content:center;transition:--radialprogress .3s linear;display:inline-grid;position:relative}.\32 xl\:radial-progress:before{content:"";background:radial-gradient(farthest-side, currentColor 98%, #0000) top/var(--thickness) var(--thickness) no-repeat, conic-gradient(currentColor var(--radialprogress), #0000 0);-webkit-mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));mask:radial-gradient(farthest-side, #0000 calc(100% - var(--thickness)), #000 calc(100% + .5px - var(--thickness)));border-radius:3.40282e38px;position:absolute;inset:0}.\32 xl\:radial-progress:after{content:"";inset:calc(50% - var(--thickness) / 2);transform:rotate(calc(var(--value) * 3.6deg - 90deg)) translate(calc(var(--size) / 2 - 50%));background-color:currentColor;border-radius:3.40282e38px;transition:transform .3s linear;position:absolute}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.sm\:checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.sm\:checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.sm\:checkbox:checked,.sm\:checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));&:before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1;translate:3.5% -7%}@media (forced-colors:active){&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}}.sm\:checkbox:indeterminate,.sm\:checkbox[aria-checked=mixed]{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));&:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -40%;rotate:0deg}}}@layer daisyui.l1.l2{.sm\:checkbox-primary{color:var(--color-primary-content);--input-color:var(--color-primary)}.sm\:checkbox-secondary{color:var(--color-secondary-content);--input-color:var(--color-secondary)}.sm\:checkbox-accent{color:var(--color-accent-content);--input-color:var(--color-accent)}.sm\:checkbox-neutral{color:var(--color-neutral-content);--input-color:var(--color-neutral)}.sm\:checkbox-info{color:var(--color-info-content);--input-color:var(--color-info)}.sm\:checkbox-success{color:var(--color-success-content);--input-color:var(--color-success)}.sm\:checkbox-warning{color:var(--color-warning-content);--input-color:var(--color-warning)}.sm\:checkbox-error{color:var(--color-error-content);--input-color:var(--color-error)}.sm\:checkbox:disabled{cursor:not-allowed;opacity:.2}.sm\:checkbox-xs{--size:calc(var(--size-selector,.25rem) * 4);padding:.125rem}.sm\:checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5);padding:.1875rem}.sm\:checkbox-md{--size:calc(var(--size-selector,.25rem) * 6);padding:.25rem}.sm\:checkbox-lg{--size:calc(var(--size-selector,.25rem) * 7);padding:.3125rem}.sm\:checkbox-xl{--size:calc(var(--size-selector,.25rem) * 8);padding:.375rem}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.md\:checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.md\:checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.md\:checkbox:checked,.md\:checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));&:before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1;translate:3.5% -7%}@media (forced-colors:active){&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}}.md\:checkbox:indeterminate,.md\:checkbox[aria-checked=mixed]{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));&:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -40%;rotate:0deg}}}@layer daisyui.l1.l2{.md\:checkbox-primary{color:var(--color-primary-content);--input-color:var(--color-primary)}.md\:checkbox-secondary{color:var(--color-secondary-content);--input-color:var(--color-secondary)}.md\:checkbox-accent{color:var(--color-accent-content);--input-color:var(--color-accent)}.md\:checkbox-neutral{color:var(--color-neutral-content);--input-color:var(--color-neutral)}.md\:checkbox-info{color:var(--color-info-content);--input-color:var(--color-info)}.md\:checkbox-success{color:var(--color-success-content);--input-color:var(--color-success)}.md\:checkbox-warning{color:var(--color-warning-content);--input-color:var(--color-warning)}.md\:checkbox-error{color:var(--color-error-content);--input-color:var(--color-error)}.md\:checkbox:disabled{cursor:not-allowed;opacity:.2}.md\:checkbox-xs{--size:calc(var(--size-selector,.25rem) * 4);padding:.125rem}.md\:checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5);padding:.1875rem}.md\:checkbox-md{--size:calc(var(--size-selector,.25rem) * 6);padding:.25rem}.md\:checkbox-lg{--size:calc(var(--size-selector,.25rem) * 7);padding:.3125rem}.md\:checkbox-xl{--size:calc(var(--size-selector,.25rem) * 8);padding:.375rem}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.lg\:checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.lg\:checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.lg\:checkbox:checked,.lg\:checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));&:before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1;translate:3.5% -7%}@media (forced-colors:active){&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}}.lg\:checkbox:indeterminate,.lg\:checkbox[aria-checked=mixed]{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));&:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -40%;rotate:0deg}}}@layer daisyui.l1.l2{.lg\:checkbox-primary{color:var(--color-primary-content);--input-color:var(--color-primary)}.lg\:checkbox-secondary{color:var(--color-secondary-content);--input-color:var(--color-secondary)}.lg\:checkbox-accent{color:var(--color-accent-content);--input-color:var(--color-accent)}.lg\:checkbox-neutral{color:var(--color-neutral-content);--input-color:var(--color-neutral)}.lg\:checkbox-info{color:var(--color-info-content);--input-color:var(--color-info)}.lg\:checkbox-success{color:var(--color-success-content);--input-color:var(--color-success)}.lg\:checkbox-warning{color:var(--color-warning-content);--input-color:var(--color-warning)}.lg\:checkbox-error{color:var(--color-error-content);--input-color:var(--color-error)}.lg\:checkbox:disabled{cursor:not-allowed;opacity:.2}.lg\:checkbox-xs{--size:calc(var(--size-selector,.25rem) * 4);padding:.125rem}.lg\:checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5);padding:.1875rem}.lg\:checkbox-md{--size:calc(var(--size-selector,.25rem) * 6);padding:.25rem}.lg\:checkbox-lg{--size:calc(var(--size-selector,.25rem) * 7);padding:.3125rem}.lg\:checkbox-xl{--size:calc(var(--size-selector,.25rem) * 8);padding:.375rem}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.xl\:checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.xl\:checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.xl\:checkbox:checked,.xl\:checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));&:before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1;translate:3.5% -7%}@media (forced-colors:active){&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}}.xl\:checkbox:indeterminate,.xl\:checkbox[aria-checked=mixed]{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));&:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -40%;rotate:0deg}}}@layer daisyui.l1.l2{.xl\:checkbox-primary{color:var(--color-primary-content);--input-color:var(--color-primary)}.xl\:checkbox-secondary{color:var(--color-secondary-content);--input-color:var(--color-secondary)}.xl\:checkbox-accent{color:var(--color-accent-content);--input-color:var(--color-accent)}.xl\:checkbox-neutral{color:var(--color-neutral-content);--input-color:var(--color-neutral)}.xl\:checkbox-info{color:var(--color-info-content);--input-color:var(--color-info)}.xl\:checkbox-success{color:var(--color-success-content);--input-color:var(--color-success)}.xl\:checkbox-warning{color:var(--color-warning-content);--input-color:var(--color-warning)}.xl\:checkbox-error{color:var(--color-error-content);--input-color:var(--color-error)}.xl\:checkbox:disabled{cursor:not-allowed;opacity:.2}.xl\:checkbox-xs{--size:calc(var(--size-selector,.25rem) * 4);padding:.125rem}.xl\:checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5);padding:.1875rem}.xl\:checkbox-md{--size:calc(var(--size-selector,.25rem) * 6);padding:.25rem}.xl\:checkbox-lg{--size:calc(var(--size-selector,.25rem) * 7);padding:.3125rem}.xl\:checkbox-xl{--size:calc(var(--size-selector,.25rem) * 8);padding:.375rem}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.\32 xl\:checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.\32 xl\:checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.\32 xl\:checkbox:checked,.\32 xl\:checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));&:before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1;translate:3.5% -7%}@media (forced-colors:active){&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{&:before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}}.\32 xl\:checkbox:indeterminate,.\32 xl\:checkbox[aria-checked=mixed]{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000));&:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -40%;rotate:0deg}}}@layer daisyui.l1.l2{.\32 xl\:checkbox-primary{color:var(--color-primary-content);--input-color:var(--color-primary)}.\32 xl\:checkbox-secondary{color:var(--color-secondary-content);--input-color:var(--color-secondary)}.\32 xl\:checkbox-accent{color:var(--color-accent-content);--input-color:var(--color-accent)}.\32 xl\:checkbox-neutral{color:var(--color-neutral-content);--input-color:var(--color-neutral)}.\32 xl\:checkbox-info{color:var(--color-info-content);--input-color:var(--color-info)}.\32 xl\:checkbox-success{color:var(--color-success-content);--input-color:var(--color-success)}.\32 xl\:checkbox-warning{color:var(--color-warning-content);--input-color:var(--color-warning)}.\32 xl\:checkbox-error{color:var(--color-error-content);--input-color:var(--color-error)}.\32 xl\:checkbox:disabled{cursor:not-allowed;opacity:.2}.\32 xl\:checkbox-xs{--size:calc(var(--size-selector,.25rem) * 4);padding:.125rem}.\32 xl\:checkbox-sm{--size:calc(var(--size-selector,.25rem) * 5);padding:.1875rem}.\32 xl\:checkbox-md{--size:calc(var(--size-selector,.25rem) * 6);padding:.25rem}.\32 xl\:checkbox-lg{--size:calc(var(--size-selector,.25rem) * 7);padding:.3125rem}.\32 xl\:checkbox-xl{--size:calc(var(--size-selector,.25rem) * 8);padding:.375rem}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.sm\:drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}:where(.drawer-side){overflow:hidden}.sm\:drawer-side{pointer-events:none;visibility:hidden;inset-inline-start:0;z-index:10;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete, visibility .3s ease-out .1s allow-discrete;background-color:#0000;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0}.sm\:drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.sm\:drawer-side>*{grid-row-start:1;grid-column-start:1}.sm\:drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out,width .2s ease-out;translate:-100%;[dir=rtl] &{translate:100%}}.sm\:drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4));&>:not(.drawer-overlay){will-change:auto;transform:none}}:where(:root:has(.drawer-toggle:checked)){--page-scroll-lock: }.sm\:drawer-open>.drawer-toggle:checked~.drawer-side{scrollbar-color:revert-layer}:root:has(.drawer-open>.drawer-toggle:checked){--page-scroll-lock:revert-layer}}@layer daisyui.l1.l2{:where(.drawer-toggle:checked~.drawer-side){pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto;&>:not(.drawer-overlay){translate:0%}}.sm\:drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.sm\:drawer-end{grid-auto-columns:auto max-content}.sm\:drawer-end>.drawer-toggle~.drawer-content{grid-column-start:1}.sm\:drawer-end>.drawer-toggle~.drawer-side{grid-column-start:2;justify-items:end}.sm\:drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:100%;[dir=rtl] &{translate:-100%}}.sm\:drawer-end>.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.sm\:drawer-open>.drawer-side{overflow-y:auto}.sm\:drawer-open>.drawer-toggle{display:none;&~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky;&>.drawer-overlay{cursor:default;background-color:#0000}}&:checked~.drawer-side{pointer-events:auto;visibility:visible}}}@layer daisyui.l1{.sm\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:0%;[dir=rtl] &{translate:0%}}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.md\:drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}:where(.drawer-side){overflow:hidden}.md\:drawer-side{pointer-events:none;visibility:hidden;inset-inline-start:0;z-index:10;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete, visibility .3s ease-out .1s allow-discrete;background-color:#0000;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0}.md\:drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.md\:drawer-side>*{grid-row-start:1;grid-column-start:1}.md\:drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out,width .2s ease-out;translate:-100%;[dir=rtl] &{translate:100%}}.md\:drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4));&>:not(.drawer-overlay){will-change:auto;transform:none}}:where(:root:has(.drawer-toggle:checked)){--page-scroll-lock: }.md\:drawer-open>.drawer-toggle:checked~.drawer-side{scrollbar-color:revert-layer}:root:has(.drawer-open>.drawer-toggle:checked){--page-scroll-lock:revert-layer}}@layer daisyui.l1.l2{:where(.drawer-toggle:checked~.drawer-side){pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto;&>:not(.drawer-overlay){translate:0%}}.md\:drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.md\:drawer-end{grid-auto-columns:auto max-content}.md\:drawer-end>.drawer-toggle~.drawer-content{grid-column-start:1}.md\:drawer-end>.drawer-toggle~.drawer-side{grid-column-start:2;justify-items:end}.md\:drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:100%;[dir=rtl] &{translate:-100%}}.md\:drawer-end>.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.md\:drawer-open>.drawer-side{overflow-y:auto}.md\:drawer-open>.drawer-toggle{display:none;&~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky;&>.drawer-overlay{cursor:default;background-color:#0000}}&:checked~.drawer-side{pointer-events:auto;visibility:visible}}}@layer daisyui.l1{.md\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:0%;[dir=rtl] &{translate:0%}}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.lg\:drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}:where(.drawer-side){overflow:hidden}.lg\:drawer-side{pointer-events:none;visibility:hidden;inset-inline-start:0;z-index:10;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete, visibility .3s ease-out .1s allow-discrete;background-color:#0000;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0}.lg\:drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.lg\:drawer-side>*{grid-row-start:1;grid-column-start:1}.lg\:drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out,width .2s ease-out;translate:-100%;[dir=rtl] &{translate:100%}}.lg\:drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4));&>:not(.drawer-overlay){will-change:auto;transform:none}}:where(:root:has(.drawer-toggle:checked)){--page-scroll-lock: }.lg\:drawer-open>.drawer-toggle:checked~.drawer-side{scrollbar-color:revert-layer}:root:has(.drawer-open>.drawer-toggle:checked){--page-scroll-lock:revert-layer}}@layer daisyui.l1.l2{:where(.drawer-toggle:checked~.drawer-side){pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto;&>:not(.drawer-overlay){translate:0%}}.lg\:drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.lg\:drawer-end{grid-auto-columns:auto max-content}.lg\:drawer-end>.drawer-toggle~.drawer-content{grid-column-start:1}.lg\:drawer-end>.drawer-toggle~.drawer-side{grid-column-start:2;justify-items:end}.lg\:drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:100%;[dir=rtl] &{translate:-100%}}.lg\:drawer-end>.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.lg\:drawer-open>.drawer-side{overflow-y:auto}.lg\:drawer-open>.drawer-toggle{display:none;&~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky;&>.drawer-overlay{cursor:default;background-color:#0000}}&:checked~.drawer-side{pointer-events:auto;visibility:visible}}}@layer daisyui.l1{.lg\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:0%;[dir=rtl] &{translate:0%}}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.xl\:drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}:where(.drawer-side){overflow:hidden}.xl\:drawer-side{pointer-events:none;visibility:hidden;inset-inline-start:0;z-index:10;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete, visibility .3s ease-out .1s allow-discrete;background-color:#0000;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0}.xl\:drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.xl\:drawer-side>*{grid-row-start:1;grid-column-start:1}.xl\:drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out,width .2s ease-out;translate:-100%;[dir=rtl] &{translate:100%}}.xl\:drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4));&>:not(.drawer-overlay){will-change:auto;transform:none}}:where(:root:has(.drawer-toggle:checked)){--page-scroll-lock: }.xl\:drawer-open>.drawer-toggle:checked~.drawer-side{scrollbar-color:revert-layer}:root:has(.drawer-open>.drawer-toggle:checked){--page-scroll-lock:revert-layer}}@layer daisyui.l1.l2{:where(.drawer-toggle:checked~.drawer-side){pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto;&>:not(.drawer-overlay){translate:0%}}.xl\:drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.xl\:drawer-end{grid-auto-columns:auto max-content}.xl\:drawer-end>.drawer-toggle~.drawer-content{grid-column-start:1}.xl\:drawer-end>.drawer-toggle~.drawer-side{grid-column-start:2;justify-items:end}.xl\:drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:100%;[dir=rtl] &{translate:-100%}}.xl\:drawer-end>.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.xl\:drawer-open>.drawer-side{overflow-y:auto}.xl\:drawer-open>.drawer-toggle{display:none;&~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky;&>.drawer-overlay{cursor:default;background-color:#0000}}&:checked~.drawer-side{pointer-events:auto;visibility:visible}}}@layer daisyui.l1{.xl\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:0%;[dir=rtl] &{translate:0%}}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:drawer{grid-auto-columns:max-content auto;width:100%;display:grid;position:relative}.\32 xl\:drawer-content{grid-row-start:1;grid-column-start:2;min-width:0}:where(.drawer-side){overflow:hidden}.\32 xl\:drawer-side{pointer-events:none;visibility:hidden;inset-inline-start:0;z-index:10;overscroll-behavior:contain;opacity:0;width:100%;transition:opacity .2s ease-out .1s allow-discrete, visibility .3s ease-out .1s allow-discrete;background-color:#0000;grid-template-rows:repeat(1,minmax(0,1fr));grid-template-columns:repeat(1,minmax(0,1fr));grid-row-start:1;grid-column-start:1;place-items:flex-start start;height:100dvh;display:grid;position:fixed;top:0}.\32 xl\:drawer-side>.drawer-overlay{cursor:pointer;background-color:oklch(0% 0 0/.4);place-self:stretch stretch;position:sticky;top:0}.\32 xl\:drawer-side>*{grid-row-start:1;grid-column-start:1}.\32 xl\:drawer-side>:not(.drawer-overlay){will-change:transform;transition:translate .3s ease-out,width .2s ease-out;translate:-100%;[dir=rtl] &{translate:100%}}.\32 xl\:drawer-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}:where(.drawer-toggle:checked~.drawer-side){scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) oklch(0 0 0 / calc(var(--page-has-backdrop,0) * .4));&>:not(.drawer-overlay){will-change:auto;transform:none}}:where(:root:has(.drawer-toggle:checked)){--page-scroll-lock: }.\32 xl\:drawer-open>.drawer-toggle:checked~.drawer-side{scrollbar-color:revert-layer}:root:has(.drawer-open>.drawer-toggle:checked){--page-scroll-lock:revert-layer}}@layer daisyui.l1.l2{:where(.drawer-toggle:checked~.drawer-side){pointer-events:auto;visibility:visible;opacity:1;overflow-y:auto;&>:not(.drawer-overlay){translate:0%}}.\32 xl\:drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline:2px solid}.\32 xl\:drawer-end{grid-auto-columns:auto max-content}.\32 xl\:drawer-end>.drawer-toggle~.drawer-content{grid-column-start:1}.\32 xl\:drawer-end>.drawer-toggle~.drawer-side{grid-column-start:2;justify-items:end}.\32 xl\:drawer-end>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:100%;[dir=rtl] &{translate:-100%}}.\32 xl\:drawer-end>.drawer-toggle:checked~.drawer-side>:not(.drawer-overlay){translate:0%}.\32 xl\:drawer-open>.drawer-side{overflow-y:auto}.\32 xl\:drawer-open>.drawer-toggle{display:none;&~.drawer-side{pointer-events:auto;visibility:visible;overscroll-behavior:auto;opacity:1;width:auto;display:block;position:sticky;&>.drawer-overlay{cursor:default;background-color:#0000}}&:checked~.drawer-side{pointer-events:auto;visibility:visible}}}@layer daisyui.l1{.\32 xl\:drawer-open>.drawer-toggle~.drawer-side>:not(.drawer-overlay){translate:0%;[dir=rtl] &{translate:0%}}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:overlay .3s allow-discrete, visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.sm\:modal::backdrop{display:none}.sm\:modal[popover]{color:inherit;background:0 0;border:0;max-width:none;max-height:none;margin:0;padding:0;inset:0;&::backdrop{background-color:oklch(0% 0 0/.4);transition:background-color .3s ease-out}}.sm\:modal-action{justify-content:flex-end;gap:.5rem;margin-top:1.5rem;display:flex}.sm\:modal-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}.sm\:modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.sm\:modal-backdrop button{cursor:pointer}.sm\:modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:.95;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}}@layer daisyui.l1.l2{.sm\:modal.modal-open,.sm\:modal[open],.sm\:modal:popover-open,.sm\:modal:target,.sm\:modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4);&>.modal-box{opacity:1;translate:0;scale:1}:root:has(&){--page-scroll-lock: }}@starting-style{.sm\:modal.modal-open,.sm\:modal[open],.sm\:modal:popover-open,.sm\:modal:target,.sm\:modal-toggle:checked+.modal{opacity:0}}.sm\:modal-top{place-items:start}.sm\:modal-top>.modal-box{--modal-tl:0;--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 -100%;scale:1}.sm\:modal-middle{place-items:center}.sm\:modal-middle>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:91.6667%;max-width:32rem;height:auto;max-height:calc(100vh - 5em);translate:0 2%;scale:.98}.sm\:modal-bottom{place-items:end}.sm\:modal-bottom>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:0;width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 100%;scale:1}.sm\:modal-start{place-items:start}.sm\:modal-start>.modal-box{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);width:auto;max-width:none;height:100vh;max-height:none;translate:-100%;scale:1;[dir=rtl] &{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;translate:100%}}.sm\:modal-end{place-items:end}.sm\:modal-end>.modal-box{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;width:auto;max-width:none;height:100vh;max-height:none;translate:100%;scale:1;[dir=rtl] &{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);translate:-100%}}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:overlay .3s allow-discrete, visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.md\:modal::backdrop{display:none}.md\:modal[popover]{color:inherit;background:0 0;border:0;max-width:none;max-height:none;margin:0;padding:0;inset:0;&::backdrop{background-color:oklch(0% 0 0/.4);transition:background-color .3s ease-out}}.md\:modal-action{justify-content:flex-end;gap:.5rem;margin-top:1.5rem;display:flex}.md\:modal-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}.md\:modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.md\:modal-backdrop button{cursor:pointer}.md\:modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:.95;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}}@layer daisyui.l1.l2{.md\:modal.modal-open,.md\:modal[open],.md\:modal:popover-open,.md\:modal:target,.md\:modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4);&>.modal-box{opacity:1;translate:0;scale:1}:root:has(&){--page-scroll-lock: }}@starting-style{.md\:modal.modal-open,.md\:modal[open],.md\:modal:popover-open,.md\:modal:target,.md\:modal-toggle:checked+.modal{opacity:0}}.md\:modal-top{place-items:start}.md\:modal-top>.modal-box{--modal-tl:0;--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 -100%;scale:1}.md\:modal-middle{place-items:center}.md\:modal-middle>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:91.6667%;max-width:32rem;height:auto;max-height:calc(100vh - 5em);translate:0 2%;scale:.98}.md\:modal-bottom{place-items:end}.md\:modal-bottom>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:0;width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 100%;scale:1}.md\:modal-start{place-items:start}.md\:modal-start>.modal-box{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);width:auto;max-width:none;height:100vh;max-height:none;translate:-100%;scale:1;[dir=rtl] &{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;translate:100%}}.md\:modal-end{place-items:end}.md\:modal-end>.modal-box{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;width:auto;max-width:none;height:100vh;max-height:none;translate:100%;scale:1;[dir=rtl] &{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);translate:-100%}}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:overlay .3s allow-discrete, visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.lg\:modal::backdrop{display:none}.lg\:modal[popover]{color:inherit;background:0 0;border:0;max-width:none;max-height:none;margin:0;padding:0;inset:0;&::backdrop{background-color:oklch(0% 0 0/.4);transition:background-color .3s ease-out}}.lg\:modal-action{justify-content:flex-end;gap:.5rem;margin-top:1.5rem;display:flex}.lg\:modal-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}.lg\:modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.lg\:modal-backdrop button{cursor:pointer}.lg\:modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:.95;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}}@layer daisyui.l1.l2{.lg\:modal.modal-open,.lg\:modal[open],.lg\:modal:popover-open,.lg\:modal:target,.lg\:modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4);&>.modal-box{opacity:1;translate:0;scale:1}:root:has(&){--page-scroll-lock: }}@starting-style{.lg\:modal.modal-open,.lg\:modal[open],.lg\:modal:popover-open,.lg\:modal:target,.lg\:modal-toggle:checked+.modal{opacity:0}}.lg\:modal-top{place-items:start}.lg\:modal-top>.modal-box{--modal-tl:0;--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 -100%;scale:1}.lg\:modal-middle{place-items:center}.lg\:modal-middle>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:91.6667%;max-width:32rem;height:auto;max-height:calc(100vh - 5em);translate:0 2%;scale:.98}.lg\:modal-bottom{place-items:end}.lg\:modal-bottom>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:0;width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 100%;scale:1}.lg\:modal-start{place-items:start}.lg\:modal-start>.modal-box{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);width:auto;max-width:none;height:100vh;max-height:none;translate:-100%;scale:1;[dir=rtl] &{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;translate:100%}}.lg\:modal-end{place-items:end}.lg\:modal-end>.modal-box{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;width:auto;max-width:none;height:100vh;max-height:none;translate:100%;scale:1;[dir=rtl] &{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);translate:-100%}}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:overlay .3s allow-discrete, visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.xl\:modal::backdrop{display:none}.xl\:modal[popover]{color:inherit;background:0 0;border:0;max-width:none;max-height:none;margin:0;padding:0;inset:0;&::backdrop{background-color:oklch(0% 0 0/.4);transition:background-color .3s ease-out}}.xl\:modal-action{justify-content:flex-end;gap:.5rem;margin-top:1.5rem;display:flex}.xl\:modal-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}.xl\:modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.xl\:modal-backdrop button{cursor:pointer}.xl\:modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:.95;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}}@layer daisyui.l1.l2{.xl\:modal.modal-open,.xl\:modal[open],.xl\:modal:popover-open,.xl\:modal:target,.xl\:modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4);&>.modal-box{opacity:1;translate:0;scale:1}:root:has(&){--page-scroll-lock: }}@starting-style{.xl\:modal.modal-open,.xl\:modal[open],.xl\:modal:popover-open,.xl\:modal:target,.xl\:modal-toggle:checked+.modal{opacity:0}}.xl\:modal-top{place-items:start}.xl\:modal-top>.modal-box{--modal-tl:0;--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 -100%;scale:1}.xl\:modal-middle{place-items:center}.xl\:modal-middle>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:91.6667%;max-width:32rem;height:auto;max-height:calc(100vh - 5em);translate:0 2%;scale:.98}.xl\:modal-bottom{place-items:end}.xl\:modal-bottom>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:0;width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 100%;scale:1}.xl\:modal-start{place-items:start}.xl\:modal-start>.modal-box{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);width:auto;max-width:none;height:100vh;max-height:none;translate:-100%;scale:1;[dir=rtl] &{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;translate:100%}}.xl\:modal-end{place-items:end}.xl\:modal-end>.modal-box{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;width:auto;max-width:none;height:100vh;max-height:none;translate:100%;scale:1;[dir=rtl] &{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);translate:-100%}}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:overlay .3s allow-discrete, visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.\32 xl\:modal::backdrop{display:none}.\32 xl\:modal[popover]{color:inherit;background:0 0;border:0;max-width:none;max-height:none;margin:0;padding:0;inset:0;&::backdrop{background-color:oklch(0% 0 0/.4);transition:background-color .3s ease-out}}.\32 xl\:modal-action{justify-content:flex-end;gap:.5rem;margin-top:1.5rem;display:flex}.\32 xl\:modal-toggle{appearance:none;opacity:0;width:0;height:0;position:fixed}.\32 xl\:modal-backdrop{color:#0000;z-index:-1;grid-row-start:1;grid-column-start:1;place-self:stretch stretch;display:grid}.\32 xl\:modal-backdrop button{cursor:pointer}.\32 xl\:modal-box{background-color:var(--color-base-100);border-top-left-radius:var(--modal-tl,var(--radius-box));border-top-right-radius:var(--modal-tr,var(--radius-box));border-bottom-left-radius:var(--modal-bl,var(--radius-box));border-bottom-right-radius:var(--modal-br,var(--radius-box));opacity:0;overscroll-behavior:contain;grid-row-start:1;grid-column-start:1;width:91.6667%;max-width:32rem;max-height:100vh;padding:1.5rem;transition:translate .3s ease-out,scale .3s ease-out,opacity .2s ease-out 50ms,box-shadow .3s ease-out;overflow-y:auto;scale:.95;box-shadow:0 25px 50px -12px oklch(0% 0 0/.25)}}@layer daisyui.l1.l2{.\32 xl\:modal.modal-open,.\32 xl\:modal[open],.\32 xl\:modal:popover-open,.\32 xl\:modal:target,.\32 xl\:modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4);&>.modal-box{opacity:1;translate:0;scale:1}:root:has(&){--page-scroll-lock: }}@starting-style{.\32 xl\:modal.modal-open,.\32 xl\:modal[open],.\32 xl\:modal:popover-open,.\32 xl\:modal:target,.\32 xl\:modal-toggle:checked+.modal{opacity:0}}.\32 xl\:modal-top{place-items:start}.\32 xl\:modal-top>.modal-box{--modal-tl:0;--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 -100%;scale:1}.\32 xl\:modal-middle{place-items:center}.\32 xl\:modal-middle>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:var(--radius-box);--modal-br:var(--radius-box);width:91.6667%;max-width:32rem;height:auto;max-height:calc(100vh - 5em);translate:0 2%;scale:.98}.\32 xl\:modal-bottom{place-items:end}.\32 xl\:modal-bottom>.modal-box{--modal-tl:var(--radius-box);--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:0;width:100%;max-width:none;height:auto;max-height:calc(100vh - 5em);translate:0 100%;scale:1}.\32 xl\:modal-start{place-items:start}.\32 xl\:modal-start>.modal-box{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);width:auto;max-width:none;height:100vh;max-height:none;translate:-100%;scale:1;[dir=rtl] &{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;translate:100%}}.\32 xl\:modal-end{place-items:end}.\32 xl\:modal-end>.modal-box{--modal-tl:var(--radius-box);--modal-tr:0;--modal-bl:var(--radius-box);--modal-br:0;width:auto;max-width:none;height:100vh;max-height:none;translate:100%;scale:1;[dir=rtl] &{--modal-tl:0;--modal-tr:var(--radius-box);--modal-bl:0;--modal-br:var(--radius-box);translate:-100%}}}}:is(.validator:user-invalid,.validator:has(:user-invalid),.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))~.validator-hint{display:revert-layer}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.sm\:footer>:not(script,style,template){place-items:start;gap:.5rem;display:grid}.sm\:footer-title{text-transform:uppercase;opacity:.6;margin-bottom:.5rem;font-weight:600}}@layer daisyui.l1.l2{.sm\:footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.sm\:footer-center>:not(script,style,template){place-items:center}.sm\:footer-horizontal{grid-auto-flow:column}.sm\:footer-horizontal.footer-center{grid-auto-flow:dense}.sm\:footer-vertical{grid-auto-flow:row}.sm\:footer-vertical.footer-center{grid-auto-flow:column dense}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.md\:footer>:not(script,style,template){place-items:start;gap:.5rem;display:grid}.md\:footer-title{text-transform:uppercase;opacity:.6;margin-bottom:.5rem;font-weight:600}}@layer daisyui.l1.l2{.md\:footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.md\:footer-center>:not(script,style,template){place-items:center}.md\:footer-horizontal{grid-auto-flow:column}.md\:footer-horizontal.footer-center{grid-auto-flow:dense}.md\:footer-vertical{grid-auto-flow:row}.md\:footer-vertical.footer-center{grid-auto-flow:column dense}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.lg\:footer>:not(script,style,template){place-items:start;gap:.5rem;display:grid}.lg\:footer-title{text-transform:uppercase;opacity:.6;margin-bottom:.5rem;font-weight:600}}@layer daisyui.l1.l2{.lg\:footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.lg\:footer-center>:not(script,style,template){place-items:center}.lg\:footer-horizontal{grid-auto-flow:column}.lg\:footer-horizontal.footer-center{grid-auto-flow:dense}.lg\:footer-vertical{grid-auto-flow:row}.lg\:footer-vertical.footer-center{grid-auto-flow:column dense}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.xl\:footer>:not(script,style,template){place-items:start;gap:.5rem;display:grid}.xl\:footer-title{text-transform:uppercase;opacity:.6;margin-bottom:.5rem;font-weight:600}}@layer daisyui.l1.l2{.xl\:footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.xl\:footer-center>:not(script,style,template){place-items:center}.xl\:footer-horizontal{grid-auto-flow:column}.xl\:footer-horizontal.footer-center{grid-auto-flow:dense}.xl\:footer-vertical{grid-auto-flow:row}.xl\:footer-vertical.footer-center{grid-auto-flow:column dense}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.\32 xl\:footer>:not(script,style,template){place-items:start;gap:.5rem;display:grid}.\32 xl\:footer-title{text-transform:uppercase;opacity:.6;margin-bottom:.5rem;font-weight:600}}@layer daisyui.l1.l2{.\32 xl\:footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.\32 xl\:footer-center>:not(script,style,template){place-items:center}.\32 xl\:footer-horizontal{grid-auto-flow:column}.\32 xl\:footer-horizontal.footer-center{grid-auto-flow:dense}.\32 xl\:footer-vertical{grid-auto-flow:row}.\32 xl\:footer-vertical.footer-center{grid-auto-flow:column dense}}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.sm\:menu :where(li ul,li menu){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;&:before{inset-inline-start:0;background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";position:absolute;top:.75rem;bottom:.75rem}}.sm\:menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.sm\:menu :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title,.btn)),.sm\:menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.sm\:menu :where(li>details>summary){--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}&::-webkit-details-marker{display:none}}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.sm\:menu details{interpolate-size:allow-keywords;overflow:hidden}.sm\:menu details::details-content{block-size:0;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.sm\:menu details[open]::details-content{block-size:auto}.sm\:menu :where(li>details[open]>summary):after,.sm\:menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.sm\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])).menu-focus,.sm\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);color:var(--color-base-content);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.sm\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover){cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.sm\:menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.sm\:menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative;& .badge{justify-self:flex-end}&>:not(ul,menu,.menu-title,details,.btn):active,&>:not(ul,menu,.menu-title,details,.btn).menu-active,&>:not(ul,menu,.menu-title,details,.btn)[aria-current]:not([aria-current=false],[aria-current=""]),&>details>summary:active{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);&:not(&:active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}}&.menu-disabled,& [disabled]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.sm\:menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.sm\:menu .dropdown-content{margin-top:.5rem;padding:.5rem;&:before{display:none}}.sm\:menu-title{color:color-mix(in oklab, var(--color-base-content) 40%, transparent);padding-block:.5rem;padding-inline:.75rem;font-size:.875rem;font-weight:600}}@layer daisyui.l1.l2{.sm\:menu-horizontal{flex-direction:row;align-items:flex-start;display:inline-flex}.sm\:menu-horizontal>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:0;transform-origin:top;border-radius:var(--radius-box);background-color:var(--color-base-100);opacity:0;margin-top:1rem;padding-block:.5rem;padding-inline-end:.5rem;position:absolute;scale:.95;box-shadow:0 1px 3px oklch(0% 0 0/.1),0 1px 2px -1px oklch(0% 0 0/.1);@media (prefers-reduced-motion:no-preference){@starting-style{opacity:0;scale:.95}transition-behavior:allow-discrete;transition-property:opacity,scale,display;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s menu}}.sm\:menu-horizontal>li:not(.menu-title)>details[open]>:is(ul,menu){opacity:1;scale:1}.sm\:menu-horizontal>li>details>:is(ul,menu):before{--tw-content:none;content:var(--tw-content)}.sm\:menu-vertical{flex-direction:column;align-items:stretch;display:inline-flex}.sm\:menu-vertical>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:1rem;background-color:revert-layer;border-radius:revert-layer;animation:revert-layer;box-shadow:revert-layer;margin-top:0;padding-block:0;padding-inline-end:0;transition:revert-layer;position:relative}.sm\:menu-paged{--menu-paged-arrow:135deg;--menu-paged-back-arrow:-45deg}[dir=rtl] .menu-paged{--menu-paged-arrow:-45deg;--menu-paged-back-arrow:135deg}.sm\:menu-paged :where(li ul,li menu):before{--tw-content:none;content:var(--tw-content)}.sm\:menu-paged details[open]>summary{font-size:0;&:not(:active){transition-duration:0s}&>*{display:none}&:before{--tw-content:"Back";content:var(--tw-content);font-size:.875rem}&[aria-label]:before{--tw-content:attr(aria-label);content:var(--tw-content)}}.sm\:menu-paged details::details-content{transition:none}.sm\:menu-paged:has(>li>details[open])>li:not(:has(>details[open])),.sm\:menu-paged :where(:is(ul,menu):has(>li>details[open])>li:not(:has(>details[open]))){display:none}.sm\:menu-paged :where(li:has(>details[open]),details[open],details[open]>:is(ul,menu)),.sm\:menu-paged :where(details[open])::details-content{display:contents}.sm\:menu-paged :where(details[open]:has(>:is(ul,menu)>li>details[open])>summary){display:none}.sm\:menu-paged :where(li>details>summary):after{rotate:var(--menu-paged-arrow);transition:none;translate:0}.sm\:menu-paged :where(li>details[open]>summary):after{rotate:var(--menu-paged-back-arrow);order:-1;justify-self:flex-start}.sm\:menu-xs :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.sm\:menu-xs :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.5rem;font-size:.6875rem}.sm\:menu-xs details[open]>summary:before{font-size:.6875rem}.sm\:menu-xs .menu-title{padding-block:.25rem;padding-inline:.5rem}.sm\:menu-sm :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.sm\:menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.625rem;font-size:.75rem}.sm\:menu-sm details[open]>summary:before{font-size:.75rem}.sm\:menu-sm .menu-title{padding-block:.5rem;padding-inline:.75rem}.sm\:menu-md :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.sm\:menu-md :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:.75rem;font-size:.875rem}.sm\:menu-md details[open]>summary:before{font-size:.875rem}.sm\:menu-md .menu-title{padding-block:.5rem;padding-inline:.75rem}.sm\:menu-lg :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.sm\:menu-lg :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1rem;font-size:1.125rem}.sm\:menu-lg details[open]>summary:before{font-size:1.125rem}.sm\:menu-lg .menu-title{padding-block:.75rem;padding-inline:1.5rem}.sm\:menu-xl :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.sm\:menu-xl :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1.25rem;font-size:1.375rem}.sm\:menu-xl details[open]>summary:before{font-size:1.375rem}.sm\:menu-xl .menu-title{padding-block:.75rem;padding-inline:1.5rem}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{outline-offset:2px;outline:2px solid #0000}}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.md\:menu :where(li ul,li menu){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;&:before{inset-inline-start:0;background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";position:absolute;top:.75rem;bottom:.75rem}}.md\:menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.md\:menu :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title,.btn)),.md\:menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.md\:menu :where(li>details>summary){--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}&::-webkit-details-marker{display:none}}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.md\:menu details{interpolate-size:allow-keywords;overflow:hidden}.md\:menu details::details-content{block-size:0;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.md\:menu details[open]::details-content{block-size:auto}.md\:menu :where(li>details[open]>summary):after,.md\:menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.md\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])).menu-focus,.md\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);color:var(--color-base-content);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.md\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover){cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.md\:menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.md\:menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative;& .badge{justify-self:flex-end}&>:not(ul,menu,.menu-title,details,.btn):active,&>:not(ul,menu,.menu-title,details,.btn).menu-active,&>:not(ul,menu,.menu-title,details,.btn)[aria-current]:not([aria-current=false],[aria-current=""]),&>details>summary:active{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);&:not(&:active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}}&.menu-disabled,& [disabled]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.md\:menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.md\:menu .dropdown-content{margin-top:.5rem;padding:.5rem;&:before{display:none}}.md\:menu-title{color:color-mix(in oklab, var(--color-base-content) 40%, transparent);padding-block:.5rem;padding-inline:.75rem;font-size:.875rem;font-weight:600}}@layer daisyui.l1.l2{.md\:menu-horizontal{flex-direction:row;align-items:flex-start;display:inline-flex}.md\:menu-horizontal>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:0;transform-origin:top;border-radius:var(--radius-box);background-color:var(--color-base-100);opacity:0;margin-top:1rem;padding-block:.5rem;padding-inline-end:.5rem;position:absolute;scale:.95;box-shadow:0 1px 3px oklch(0% 0 0/.1),0 1px 2px -1px oklch(0% 0 0/.1);@media (prefers-reduced-motion:no-preference){@starting-style{opacity:0;scale:.95}transition-behavior:allow-discrete;transition-property:opacity,scale,display;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s menu}}.md\:menu-horizontal>li:not(.menu-title)>details[open]>:is(ul,menu){opacity:1;scale:1}.md\:menu-horizontal>li>details>:is(ul,menu):before{--tw-content:none;content:var(--tw-content)}.md\:menu-vertical{flex-direction:column;align-items:stretch;display:inline-flex}.md\:menu-vertical>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:1rem;background-color:revert-layer;border-radius:revert-layer;animation:revert-layer;box-shadow:revert-layer;margin-top:0;padding-block:0;padding-inline-end:0;transition:revert-layer;position:relative}.md\:menu-paged{--menu-paged-arrow:135deg;--menu-paged-back-arrow:-45deg}[dir=rtl] .menu-paged{--menu-paged-arrow:-45deg;--menu-paged-back-arrow:135deg}.md\:menu-paged :where(li ul,li menu):before{--tw-content:none;content:var(--tw-content)}.md\:menu-paged details[open]>summary{font-size:0;&:not(:active){transition-duration:0s}&>*{display:none}&:before{--tw-content:"Back";content:var(--tw-content);font-size:.875rem}&[aria-label]:before{--tw-content:attr(aria-label);content:var(--tw-content)}}.md\:menu-paged details::details-content{transition:none}.md\:menu-paged:has(>li>details[open])>li:not(:has(>details[open])),.md\:menu-paged :where(:is(ul,menu):has(>li>details[open])>li:not(:has(>details[open]))){display:none}.md\:menu-paged :where(li:has(>details[open]),details[open],details[open]>:is(ul,menu)),.md\:menu-paged :where(details[open])::details-content{display:contents}.md\:menu-paged :where(details[open]:has(>:is(ul,menu)>li>details[open])>summary){display:none}.md\:menu-paged :where(li>details>summary):after{rotate:var(--menu-paged-arrow);transition:none;translate:0}.md\:menu-paged :where(li>details[open]>summary):after{rotate:var(--menu-paged-back-arrow);order:-1;justify-self:flex-start}.md\:menu-xs :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.md\:menu-xs :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.5rem;font-size:.6875rem}.md\:menu-xs details[open]>summary:before{font-size:.6875rem}.md\:menu-xs .menu-title{padding-block:.25rem;padding-inline:.5rem}.md\:menu-sm :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.md\:menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.625rem;font-size:.75rem}.md\:menu-sm details[open]>summary:before{font-size:.75rem}.md\:menu-sm .menu-title{padding-block:.5rem;padding-inline:.75rem}.md\:menu-md :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.md\:menu-md :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:.75rem;font-size:.875rem}.md\:menu-md details[open]>summary:before{font-size:.875rem}.md\:menu-md .menu-title{padding-block:.5rem;padding-inline:.75rem}.md\:menu-lg :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.md\:menu-lg :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1rem;font-size:1.125rem}.md\:menu-lg details[open]>summary:before{font-size:1.125rem}.md\:menu-lg .menu-title{padding-block:.75rem;padding-inline:1.5rem}.md\:menu-xl :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.md\:menu-xl :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1.25rem;font-size:1.375rem}.md\:menu-xl details[open]>summary:before{font-size:1.375rem}.md\:menu-xl .menu-title{padding-block:.75rem;padding-inline:1.5rem}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{outline-offset:2px;outline:2px solid #0000}}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.lg\:menu :where(li ul,li menu){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;&:before{inset-inline-start:0;background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";position:absolute;top:.75rem;bottom:.75rem}}.lg\:menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.lg\:menu :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title,.btn)),.lg\:menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.lg\:menu :where(li>details>summary){--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}&::-webkit-details-marker{display:none}}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.lg\:menu details{interpolate-size:allow-keywords;overflow:hidden}.lg\:menu details::details-content{block-size:0;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.lg\:menu details[open]::details-content{block-size:auto}.lg\:menu :where(li>details[open]>summary):after,.lg\:menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.lg\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])).menu-focus,.lg\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);color:var(--color-base-content);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.lg\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover){cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.lg\:menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.lg\:menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative;& .badge{justify-self:flex-end}&>:not(ul,menu,.menu-title,details,.btn):active,&>:not(ul,menu,.menu-title,details,.btn).menu-active,&>:not(ul,menu,.menu-title,details,.btn)[aria-current]:not([aria-current=false],[aria-current=""]),&>details>summary:active{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);&:not(&:active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}}&.menu-disabled,& [disabled]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.lg\:menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.lg\:menu .dropdown-content{margin-top:.5rem;padding:.5rem;&:before{display:none}}.lg\:menu-title{color:color-mix(in oklab, var(--color-base-content) 40%, transparent);padding-block:.5rem;padding-inline:.75rem;font-size:.875rem;font-weight:600}}@layer daisyui.l1.l2{.lg\:menu-horizontal{flex-direction:row;align-items:flex-start;display:inline-flex}.lg\:menu-horizontal>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:0;transform-origin:top;border-radius:var(--radius-box);background-color:var(--color-base-100);opacity:0;margin-top:1rem;padding-block:.5rem;padding-inline-end:.5rem;position:absolute;scale:.95;box-shadow:0 1px 3px oklch(0% 0 0/.1),0 1px 2px -1px oklch(0% 0 0/.1);@media (prefers-reduced-motion:no-preference){@starting-style{opacity:0;scale:.95}transition-behavior:allow-discrete;transition-property:opacity,scale,display;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s menu}}.lg\:menu-horizontal>li:not(.menu-title)>details[open]>:is(ul,menu){opacity:1;scale:1}.lg\:menu-horizontal>li>details>:is(ul,menu):before{--tw-content:none;content:var(--tw-content)}.lg\:menu-vertical{flex-direction:column;align-items:stretch;display:inline-flex}.lg\:menu-vertical>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:1rem;background-color:revert-layer;border-radius:revert-layer;animation:revert-layer;box-shadow:revert-layer;margin-top:0;padding-block:0;padding-inline-end:0;transition:revert-layer;position:relative}.lg\:menu-paged{--menu-paged-arrow:135deg;--menu-paged-back-arrow:-45deg}[dir=rtl] .menu-paged{--menu-paged-arrow:-45deg;--menu-paged-back-arrow:135deg}.lg\:menu-paged :where(li ul,li menu):before{--tw-content:none;content:var(--tw-content)}.lg\:menu-paged details[open]>summary{font-size:0;&:not(:active){transition-duration:0s}&>*{display:none}&:before{--tw-content:"Back";content:var(--tw-content);font-size:.875rem}&[aria-label]:before{--tw-content:attr(aria-label);content:var(--tw-content)}}.lg\:menu-paged details::details-content{transition:none}.lg\:menu-paged:has(>li>details[open])>li:not(:has(>details[open])),.lg\:menu-paged :where(:is(ul,menu):has(>li>details[open])>li:not(:has(>details[open]))){display:none}.lg\:menu-paged :where(li:has(>details[open]),details[open],details[open]>:is(ul,menu)),.lg\:menu-paged :where(details[open])::details-content{display:contents}.lg\:menu-paged :where(details[open]:has(>:is(ul,menu)>li>details[open])>summary){display:none}.lg\:menu-paged :where(li>details>summary):after{rotate:var(--menu-paged-arrow);transition:none;translate:0}.lg\:menu-paged :where(li>details[open]>summary):after{rotate:var(--menu-paged-back-arrow);order:-1;justify-self:flex-start}.lg\:menu-xs :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.lg\:menu-xs :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.5rem;font-size:.6875rem}.lg\:menu-xs details[open]>summary:before{font-size:.6875rem}.lg\:menu-xs .menu-title{padding-block:.25rem;padding-inline:.5rem}.lg\:menu-sm :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.lg\:menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.625rem;font-size:.75rem}.lg\:menu-sm details[open]>summary:before{font-size:.75rem}.lg\:menu-sm .menu-title{padding-block:.5rem;padding-inline:.75rem}.lg\:menu-md :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.lg\:menu-md :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:.75rem;font-size:.875rem}.lg\:menu-md details[open]>summary:before{font-size:.875rem}.lg\:menu-md .menu-title{padding-block:.5rem;padding-inline:.75rem}.lg\:menu-lg :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.lg\:menu-lg :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1rem;font-size:1.125rem}.lg\:menu-lg details[open]>summary:before{font-size:1.125rem}.lg\:menu-lg .menu-title{padding-block:.75rem;padding-inline:1.5rem}.lg\:menu-xl :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.lg\:menu-xl :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1.25rem;font-size:1.375rem}.lg\:menu-xl details[open]>summary:before{font-size:1.375rem}.lg\:menu-xl .menu-title{padding-block:.75rem;padding-inline:1.5rem}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{outline-offset:2px;outline:2px solid #0000}}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.xl\:menu :where(li ul,li menu){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;&:before{inset-inline-start:0;background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";position:absolute;top:.75rem;bottom:.75rem}}.xl\:menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.xl\:menu :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title,.btn)),.xl\:menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.xl\:menu :where(li>details>summary){--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}&::-webkit-details-marker{display:none}}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.xl\:menu details{interpolate-size:allow-keywords;overflow:hidden}.xl\:menu details::details-content{block-size:0;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.xl\:menu details[open]::details-content{block-size:auto}.xl\:menu :where(li>details[open]>summary):after,.xl\:menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.xl\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])).menu-focus,.xl\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);color:var(--color-base-content);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.xl\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover){cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.xl\:menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.xl\:menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative;& .badge{justify-self:flex-end}&>:not(ul,menu,.menu-title,details,.btn):active,&>:not(ul,menu,.menu-title,details,.btn).menu-active,&>:not(ul,menu,.menu-title,details,.btn)[aria-current]:not([aria-current=false],[aria-current=""]),&>details>summary:active{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);&:not(&:active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}}&.menu-disabled,& [disabled]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.xl\:menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.xl\:menu .dropdown-content{margin-top:.5rem;padding:.5rem;&:before{display:none}}.xl\:menu-title{color:color-mix(in oklab, var(--color-base-content) 40%, transparent);padding-block:.5rem;padding-inline:.75rem;font-size:.875rem;font-weight:600}}@layer daisyui.l1.l2{.xl\:menu-horizontal{flex-direction:row;align-items:flex-start;display:inline-flex}.xl\:menu-horizontal>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:0;transform-origin:top;border-radius:var(--radius-box);background-color:var(--color-base-100);opacity:0;margin-top:1rem;padding-block:.5rem;padding-inline-end:.5rem;position:absolute;scale:.95;box-shadow:0 1px 3px oklch(0% 0 0/.1),0 1px 2px -1px oklch(0% 0 0/.1);@media (prefers-reduced-motion:no-preference){@starting-style{opacity:0;scale:.95}transition-behavior:allow-discrete;transition-property:opacity,scale,display;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s menu}}.xl\:menu-horizontal>li:not(.menu-title)>details[open]>:is(ul,menu){opacity:1;scale:1}.xl\:menu-horizontal>li>details>:is(ul,menu):before{--tw-content:none;content:var(--tw-content)}.xl\:menu-vertical{flex-direction:column;align-items:stretch;display:inline-flex}.xl\:menu-vertical>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:1rem;background-color:revert-layer;border-radius:revert-layer;animation:revert-layer;box-shadow:revert-layer;margin-top:0;padding-block:0;padding-inline-end:0;transition:revert-layer;position:relative}.xl\:menu-paged{--menu-paged-arrow:135deg;--menu-paged-back-arrow:-45deg}[dir=rtl] .menu-paged{--menu-paged-arrow:-45deg;--menu-paged-back-arrow:135deg}.xl\:menu-paged :where(li ul,li menu):before{--tw-content:none;content:var(--tw-content)}.xl\:menu-paged details[open]>summary{font-size:0;&:not(:active){transition-duration:0s}&>*{display:none}&:before{--tw-content:"Back";content:var(--tw-content);font-size:.875rem}&[aria-label]:before{--tw-content:attr(aria-label);content:var(--tw-content)}}.xl\:menu-paged details::details-content{transition:none}.xl\:menu-paged:has(>li>details[open])>li:not(:has(>details[open])),.xl\:menu-paged :where(:is(ul,menu):has(>li>details[open])>li:not(:has(>details[open]))){display:none}.xl\:menu-paged :where(li:has(>details[open]),details[open],details[open]>:is(ul,menu)),.xl\:menu-paged :where(details[open])::details-content{display:contents}.xl\:menu-paged :where(details[open]:has(>:is(ul,menu)>li>details[open])>summary){display:none}.xl\:menu-paged :where(li>details>summary):after{rotate:var(--menu-paged-arrow);transition:none;translate:0}.xl\:menu-paged :where(li>details[open]>summary):after{rotate:var(--menu-paged-back-arrow);order:-1;justify-self:flex-start}.xl\:menu-xs :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.xl\:menu-xs :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.5rem;font-size:.6875rem}.xl\:menu-xs details[open]>summary:before{font-size:.6875rem}.xl\:menu-xs .menu-title{padding-block:.25rem;padding-inline:.5rem}.xl\:menu-sm :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.xl\:menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.625rem;font-size:.75rem}.xl\:menu-sm details[open]>summary:before{font-size:.75rem}.xl\:menu-sm .menu-title{padding-block:.5rem;padding-inline:.75rem}.xl\:menu-md :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.xl\:menu-md :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:.75rem;font-size:.875rem}.xl\:menu-md details[open]>summary:before{font-size:.875rem}.xl\:menu-md .menu-title{padding-block:.5rem;padding-inline:.75rem}.xl\:menu-lg :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.xl\:menu-lg :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1rem;font-size:1.125rem}.xl\:menu-lg details[open]>summary:before{font-size:1.125rem}.xl\:menu-lg .menu-title{padding-block:.75rem;padding-inline:1.5rem}.xl\:menu-xl :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.xl\:menu-xl :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1.25rem;font-size:1.375rem}.xl\:menu-xl details[open]>summary:before{font-size:1.375rem}.xl\:menu-xl .menu-title{padding-block:.75rem;padding-inline:1.5rem}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{outline-offset:2px;outline:2px solid #0000}}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.\32 xl\:menu :where(li ul,li menu){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;&:before{inset-inline-start:0;background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";position:absolute;top:.75rem;bottom:.75rem}}.\32 xl\:menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.\32 xl\:menu :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title,.btn)),.\32 xl\:menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.\32 xl\:menu :where(li>details>summary){--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}&::-webkit-details-marker{display:none}}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.\32 xl\:menu details{interpolate-size:allow-keywords;overflow:hidden}.\32 xl\:menu details::details-content{block-size:0;@media (prefers-reduced-motion:no-preference){transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.\32 xl\:menu details[open]::details-content{block-size:auto}.\32 xl\:menu :where(li>details[open]>summary):after,.\32 xl\:menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.\32 xl\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])).menu-focus,.\32 xl\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):focus-visible{cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);color:var(--color-base-content);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}}.\32 xl\:menu :where(li:not(.menu-title,.disabled)>:not(ul,menu,details,.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn,[aria-current]:not([aria-current=false],[aria-current=""])):hover){cursor:pointer;background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent);--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.\32 xl\:menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.\32 xl\:menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative;& .badge{justify-self:flex-end}&>:not(ul,menu,.menu-title,details,.btn):active,&>:not(ul,menu,.menu-title,details,.btn).menu-active,&>:not(ul,menu,.menu-title,details,.btn)[aria-current]:not([aria-current=false],[aria-current=""]),&>details>summary:active{--tw-outline-style:none;outline-style:none;@media (forced-colors:active){outline-offset:2px;outline:2px solid #0000}color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);&:not(&:active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}}&.menu-disabled,& [disabled]{pointer-events:none;color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.\32 xl\:menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.\32 xl\:menu .dropdown-content{margin-top:.5rem;padding:.5rem;&:before{display:none}}.\32 xl\:menu-title{color:color-mix(in oklab, var(--color-base-content) 40%, transparent);padding-block:.5rem;padding-inline:.75rem;font-size:.875rem;font-weight:600}}@layer daisyui.l1.l2{.\32 xl\:menu-horizontal{flex-direction:row;align-items:flex-start;display:inline-flex}.\32 xl\:menu-horizontal>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:0;transform-origin:top;border-radius:var(--radius-box);background-color:var(--color-base-100);opacity:0;margin-top:1rem;padding-block:.5rem;padding-inline-end:.5rem;position:absolute;scale:.95;box-shadow:0 1px 3px oklch(0% 0 0/.1),0 1px 2px -1px oklch(0% 0 0/.1);@media (prefers-reduced-motion:no-preference){@starting-style{opacity:0;scale:.95}transition-behavior:allow-discrete;transition-property:opacity,scale,display;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);animation:.2s menu}}.\32 xl\:menu-horizontal>li:not(.menu-title)>details[open]>:is(ul,menu){opacity:1;scale:1}.\32 xl\:menu-horizontal>li>details>:is(ul,menu):before{--tw-content:none;content:var(--tw-content)}.\32 xl\:menu-vertical{flex-direction:column;align-items:stretch;display:inline-flex}.\32 xl\:menu-vertical>li:not(.menu-title)>details>:is(ul,menu){margin-inline-start:1rem;background-color:revert-layer;border-radius:revert-layer;animation:revert-layer;box-shadow:revert-layer;margin-top:0;padding-block:0;padding-inline-end:0;transition:revert-layer;position:relative}.\32 xl\:menu-paged{--menu-paged-arrow:135deg;--menu-paged-back-arrow:-45deg}[dir=rtl] .menu-paged{--menu-paged-arrow:-45deg;--menu-paged-back-arrow:135deg}.\32 xl\:menu-paged :where(li ul,li menu):before{--tw-content:none;content:var(--tw-content)}.\32 xl\:menu-paged details[open]>summary{font-size:0;&:not(:active){transition-duration:0s}&>*{display:none}&:before{--tw-content:"Back";content:var(--tw-content);font-size:.875rem}&[aria-label]:before{--tw-content:attr(aria-label);content:var(--tw-content)}}.\32 xl\:menu-paged details::details-content{transition:none}.\32 xl\:menu-paged:has(>li>details[open])>li:not(:has(>details[open])),.\32 xl\:menu-paged :where(:is(ul,menu):has(>li>details[open])>li:not(:has(>details[open]))){display:none}.\32 xl\:menu-paged :where(li:has(>details[open]),details[open],details[open]>:is(ul,menu)),.\32 xl\:menu-paged :where(details[open])::details-content{display:contents}.\32 xl\:menu-paged :where(details[open]:has(>:is(ul,menu)>li>details[open])>summary){display:none}.\32 xl\:menu-paged :where(li>details>summary):after{rotate:var(--menu-paged-arrow);transition:none;translate:0}.\32 xl\:menu-paged :where(li>details[open]>summary):after{rotate:var(--menu-paged-back-arrow);order:-1;justify-self:flex-start}.\32 xl\:menu-xs :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.\32 xl\:menu-xs :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.5rem;font-size:.6875rem}.\32 xl\:menu-xs details[open]>summary:before{font-size:.6875rem}.\32 xl\:menu-xs .menu-title{padding-block:.25rem;padding-inline:.5rem}.\32 xl\:menu-sm :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.\32 xl\:menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.25rem;padding-inline:.625rem;font-size:.75rem}.\32 xl\:menu-sm details[open]>summary:before{font-size:.75rem}.\32 xl\:menu-sm .menu-title{padding-block:.5rem;padding-inline:.75rem}.\32 xl\:menu-md :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.\32 xl\:menu-md :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:.75rem;font-size:.875rem}.\32 xl\:menu-md details[open]>summary:before{font-size:.875rem}.\32 xl\:menu-md .menu-title{padding-block:.5rem;padding-inline:.75rem}.\32 xl\:menu-lg :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.\32 xl\:menu-lg :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1rem;font-size:1.125rem}.\32 xl\:menu-lg details[open]>summary:before{font-size:1.125rem}.\32 xl\:menu-lg .menu-title{padding-block:.75rem;padding-inline:1.5rem}.\32 xl\:menu-xl :where(li:not(.menu-title)>:not(ul,menu,details,.menu-title)),.\32 xl\:menu-xl :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);padding-block:.375rem;padding-inline:1.25rem;font-size:1.375rem}.\32 xl\:menu-xl details[open]>summary:before{font-size:1.375rem}.\32 xl\:menu-xl .menu-title{padding-block:.75rem;padding-inline:1.5rem}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{outline-offset:2px;outline:2px solid #0000}}:where(:not(ul,menu,details,.menu-title,.btn)).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}}}@keyframes menu{0%{opacity:0}}@media (width>=640px){@layer daisyui.l1.l2.l3{.sm\:chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.sm\:chat-bubble{border-radius:var(--radius-field);background-color:var(--color-base-300);width:fit-content;color:var(--color-base-content);grid-row-end:3;min-width:2.5rem;max-width:90%;min-height:2rem;padding-block:.5rem;padding-inline:1rem;display:block;position:relative}.sm\:chat-bubble:before{background-color:inherit;content:"";width:.75rem;height:.75rem;mask-repeat:no-repeat;mask-image:var(--mask-chat);position:absolute;bottom:0;mask-position:0 -1px;mask-size:.8125rem}.sm\:chat-image{grid-row:span 2/span 2;align-self:flex-end}.sm\:chat-header{grid-row-start:1;gap:.25rem;font-size:.6875rem;display:flex}.sm\:chat-footer{grid-row-start:3;gap:.25rem;font-size:.6875rem;display:flex}}@layer daisyui.l1.l2{.sm\:chat-bubble-primary{background-color:var(--color-primary);color:var(--color-primary-content)}.sm\:chat-bubble-secondary{background-color:var(--color-secondary);color:var(--color-secondary-content)}.sm\:chat-bubble-accent{background-color:var(--color-accent);color:var(--color-accent-content)}.sm\:chat-bubble-neutral{background-color:var(--color-neutral);color:var(--color-neutral-content)}.sm\:chat-bubble-info{background-color:var(--color-info);color:var(--color-info-content)}.sm\:chat-bubble-success{background-color:var(--color-success);color:var(--color-success-content)}.sm\:chat-bubble-warning{background-color:var(--color-warning);color:var(--color-warning-content)}.sm\:chat-bubble-error{background-color:var(--color-error);color:var(--color-error-content)}.sm\:chat-start{grid-template-columns:auto 1fr;place-items:start}.sm\:chat-start .chat-header,.sm\:chat-start .chat-footer{grid-column-start:2}.sm\:chat-start .chat-image{grid-column-start:1}.sm\:chat-start .chat-bubble{border-end-start-radius:0;grid-column-start:2;&:before{inset-inline-start:-.75rem;transform:rotateY(0)}[dir=rtl] &:before{transform:rotateY(180deg)}}.sm\:chat-end{grid-template-columns:1fr auto;place-items:end}.sm\:chat-end .chat-header,.sm\:chat-end .chat-footer{grid-column-start:1}.sm\:chat-end .chat-image{grid-column-start:2}.sm\:chat-end .chat-bubble{border-end-end-radius:0;grid-column-start:1;&:before{inset-inline-start:100%;transform:rotateY(180deg)}[dir=rtl] &:before{transform:rotateY(0)}}}}@media (width>=768px){@layer daisyui.l1.l2.l3{.md\:chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.md\:chat-bubble{border-radius:var(--radius-field);background-color:var(--color-base-300);width:fit-content;color:var(--color-base-content);grid-row-end:3;min-width:2.5rem;max-width:90%;min-height:2rem;padding-block:.5rem;padding-inline:1rem;display:block;position:relative}.md\:chat-bubble:before{background-color:inherit;content:"";width:.75rem;height:.75rem;mask-repeat:no-repeat;mask-image:var(--mask-chat);position:absolute;bottom:0;mask-position:0 -1px;mask-size:.8125rem}.md\:chat-image{grid-row:span 2/span 2;align-self:flex-end}.md\:chat-header{grid-row-start:1;gap:.25rem;font-size:.6875rem;display:flex}.md\:chat-footer{grid-row-start:3;gap:.25rem;font-size:.6875rem;display:flex}}@layer daisyui.l1.l2{.md\:chat-bubble-primary{background-color:var(--color-primary);color:var(--color-primary-content)}.md\:chat-bubble-secondary{background-color:var(--color-secondary);color:var(--color-secondary-content)}.md\:chat-bubble-accent{background-color:var(--color-accent);color:var(--color-accent-content)}.md\:chat-bubble-neutral{background-color:var(--color-neutral);color:var(--color-neutral-content)}.md\:chat-bubble-info{background-color:var(--color-info);color:var(--color-info-content)}.md\:chat-bubble-success{background-color:var(--color-success);color:var(--color-success-content)}.md\:chat-bubble-warning{background-color:var(--color-warning);color:var(--color-warning-content)}.md\:chat-bubble-error{background-color:var(--color-error);color:var(--color-error-content)}.md\:chat-start{grid-template-columns:auto 1fr;place-items:start}.md\:chat-start .chat-header,.md\:chat-start .chat-footer{grid-column-start:2}.md\:chat-start .chat-image{grid-column-start:1}.md\:chat-start .chat-bubble{border-end-start-radius:0;grid-column-start:2;&:before{inset-inline-start:-.75rem;transform:rotateY(0)}[dir=rtl] &:before{transform:rotateY(180deg)}}.md\:chat-end{grid-template-columns:1fr auto;place-items:end}.md\:chat-end .chat-header,.md\:chat-end .chat-footer{grid-column-start:1}.md\:chat-end .chat-image{grid-column-start:2}.md\:chat-end .chat-bubble{border-end-end-radius:0;grid-column-start:1;&:before{inset-inline-start:100%;transform:rotateY(180deg)}[dir=rtl] &:before{transform:rotateY(0)}}}}@media (width>=1024px){@layer daisyui.l1.l2.l3{.lg\:chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.lg\:chat-bubble{border-radius:var(--radius-field);background-color:var(--color-base-300);width:fit-content;color:var(--color-base-content);grid-row-end:3;min-width:2.5rem;max-width:90%;min-height:2rem;padding-block:.5rem;padding-inline:1rem;display:block;position:relative}.lg\:chat-bubble:before{background-color:inherit;content:"";width:.75rem;height:.75rem;mask-repeat:no-repeat;mask-image:var(--mask-chat);position:absolute;bottom:0;mask-position:0 -1px;mask-size:.8125rem}.lg\:chat-image{grid-row:span 2/span 2;align-self:flex-end}.lg\:chat-header{grid-row-start:1;gap:.25rem;font-size:.6875rem;display:flex}.lg\:chat-footer{grid-row-start:3;gap:.25rem;font-size:.6875rem;display:flex}}@layer daisyui.l1.l2{.lg\:chat-bubble-primary{background-color:var(--color-primary);color:var(--color-primary-content)}.lg\:chat-bubble-secondary{background-color:var(--color-secondary);color:var(--color-secondary-content)}.lg\:chat-bubble-accent{background-color:var(--color-accent);color:var(--color-accent-content)}.lg\:chat-bubble-neutral{background-color:var(--color-neutral);color:var(--color-neutral-content)}.lg\:chat-bubble-info{background-color:var(--color-info);color:var(--color-info-content)}.lg\:chat-bubble-success{background-color:var(--color-success);color:var(--color-success-content)}.lg\:chat-bubble-warning{background-color:var(--color-warning);color:var(--color-warning-content)}.lg\:chat-bubble-error{background-color:var(--color-error);color:var(--color-error-content)}.lg\:chat-start{grid-template-columns:auto 1fr;place-items:start}.lg\:chat-start .chat-header,.lg\:chat-start .chat-footer{grid-column-start:2}.lg\:chat-start .chat-image{grid-column-start:1}.lg\:chat-start .chat-bubble{border-end-start-radius:0;grid-column-start:2;&:before{inset-inline-start:-.75rem;transform:rotateY(0)}[dir=rtl] &:before{transform:rotateY(180deg)}}.lg\:chat-end{grid-template-columns:1fr auto;place-items:end}.lg\:chat-end .chat-header,.lg\:chat-end .chat-footer{grid-column-start:1}.lg\:chat-end .chat-image{grid-column-start:2}.lg\:chat-end .chat-bubble{border-end-end-radius:0;grid-column-start:1;&:before{inset-inline-start:100%;transform:rotateY(180deg)}[dir=rtl] &:before{transform:rotateY(0)}}}}@media (width>=1280px){@layer daisyui.l1.l2.l3{.xl\:chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.xl\:chat-bubble{border-radius:var(--radius-field);background-color:var(--color-base-300);width:fit-content;color:var(--color-base-content);grid-row-end:3;min-width:2.5rem;max-width:90%;min-height:2rem;padding-block:.5rem;padding-inline:1rem;display:block;position:relative}.xl\:chat-bubble:before{background-color:inherit;content:"";width:.75rem;height:.75rem;mask-repeat:no-repeat;mask-image:var(--mask-chat);position:absolute;bottom:0;mask-position:0 -1px;mask-size:.8125rem}.xl\:chat-image{grid-row:span 2/span 2;align-self:flex-end}.xl\:chat-header{grid-row-start:1;gap:.25rem;font-size:.6875rem;display:flex}.xl\:chat-footer{grid-row-start:3;gap:.25rem;font-size:.6875rem;display:flex}}@layer daisyui.l1.l2{.xl\:chat-bubble-primary{background-color:var(--color-primary);color:var(--color-primary-content)}.xl\:chat-bubble-secondary{background-color:var(--color-secondary);color:var(--color-secondary-content)}.xl\:chat-bubble-accent{background-color:var(--color-accent);color:var(--color-accent-content)}.xl\:chat-bubble-neutral{background-color:var(--color-neutral);color:var(--color-neutral-content)}.xl\:chat-bubble-info{background-color:var(--color-info);color:var(--color-info-content)}.xl\:chat-bubble-success{background-color:var(--color-success);color:var(--color-success-content)}.xl\:chat-bubble-warning{background-color:var(--color-warning);color:var(--color-warning-content)}.xl\:chat-bubble-error{background-color:var(--color-error);color:var(--color-error-content)}.xl\:chat-start{grid-template-columns:auto 1fr;place-items:start}.xl\:chat-start .chat-header,.xl\:chat-start .chat-footer{grid-column-start:2}.xl\:chat-start .chat-image{grid-column-start:1}.xl\:chat-start .chat-bubble{border-end-start-radius:0;grid-column-start:2;&:before{inset-inline-start:-.75rem;transform:rotateY(0)}[dir=rtl] &:before{transform:rotateY(180deg)}}.xl\:chat-end{grid-template-columns:1fr auto;place-items:end}.xl\:chat-end .chat-header,.xl\:chat-end .chat-footer{grid-column-start:1}.xl\:chat-end .chat-image{grid-column-start:2}.xl\:chat-end .chat-bubble{border-end-end-radius:0;grid-column-start:1;&:before{inset-inline-start:100%;transform:rotateY(180deg)}[dir=rtl] &:before{transform:rotateY(0)}}}}@media (width>=1536px){@layer daisyui.l1.l2.l3{.\32 xl\:chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e");grid-auto-rows:min-content;column-gap:.75rem;padding-block:.25rem;display:grid}.\32 xl\:chat-bubble{border-radius:var(--radius-field);background-color:var(--color-base-300);width:fit-content;color:var(--color-base-content);grid-row-end:3;min-width:2.5rem;max-width:90%;min-height:2rem;padding-block:.5rem;padding-inline:1rem;display:block;position:relative}.\32 xl\:chat-bubble:before{background-color:inherit;content:"";width:.75rem;height:.75rem;mask-repeat:no-repeat;mask-image:var(--mask-chat);position:absolute;bottom:0;mask-position:0 -1px;mask-size:.8125rem}.\32 xl\:chat-image{grid-row:span 2/span 2;align-self:flex-end}.\32 xl\:chat-header{grid-row-start:1;gap:.25rem;font-size:.6875rem;display:flex}.\32 xl\:chat-footer{grid-row-start:3;gap:.25rem;font-size:.6875rem;display:flex}}@layer daisyui.l1.l2{.\32 xl\:chat-bubble-primary{background-color:var(--color-primary);color:var(--color-primary-content)}.\32 xl\:chat-bubble-secondary{background-color:var(--color-secondary);color:var(--color-secondary-content)}.\32 xl\:chat-bubble-accent{background-color:var(--color-accent);color:var(--color-accent-content)}.\32 xl\:chat-bubble-neutral{background-color:var(--color-neutral);color:var(--color-neutral-content)}.\32 xl\:chat-bubble-info{background-color:var(--color-info);color:var(--color-info-content)}.\32 xl\:chat-bubble-success{background-color:var(--color-success);color:var(--color-success-content)}.\32 xl\:chat-bubble-warning{background-color:var(--color-warning);color:var(--color-warning-content)}.\32 xl\:chat-bubble-error{background-color:var(--color-error);color:var(--color-error-content)}.\32 xl\:chat-start{grid-template-columns:auto 1fr;place-items:start}.\32 xl\:chat-start .chat-header,.\32 xl\:chat-start .chat-footer{grid-column-start:2}.\32 xl\:chat-start .chat-image{grid-column-start:1}.\32 xl\:chat-start .chat-bubble{border-end-start-radius:0;grid-column-start:2;&:before{inset-inline-start:-.75rem;transform:rotateY(0)}[dir=rtl] &:before{transform:rotateY(180deg)}}.\32 xl\:chat-end{grid-template-columns:1fr auto;place-items:end}.\32 xl\:chat-end .chat-header,.\32 xl\:chat-end .chat-footer{grid-column-start:1}.\32 xl\:chat-end .chat-image{grid-column-start:2}.\32 xl\:chat-end .chat-bubble{border-end-end-radius:0;grid-column-start:1;&:before{inset-inline-start:100%;transform:rotateY(180deg)}[dir=rtl] &:before{transform:rotateY(0)}}}}.glass{backdrop-filter:blur(var(--glass-blur,40px));background-color:#0000;background-image:linear-gradient(135deg, oklch(100% 0 0 / var(--glass-opacity,30%)) 0%, oklch(0% 0 0/0) 100%), linear-gradient(var(--glass-reflect-degree,100deg), oklch(100% 0 0 / var(--glass-reflect-opacity,5%)) 25%, oklch(0% 0 0/0) 25%);box-shadow:0 0 0 1px oklch(100% 0 0 / var(--glass-border-opacity,20%)) inset, 0 0 0 2px oklch(0% 0 0/.05);text-shadow:0 1px oklch(0% 0 0 / var(--glass-text-shadow-opacity,5%));border:none}.join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;--join-ml:0;--join-mt:0;--join-v:0;--join-h:1;align-items:stretch;display:inline-flex;@scope(&){&>:where(:focus,:has(:focus)){z-index:2}@media (hover:hover){&>:where(.btn:hover,:has(.btn:hover)){z-index:1}}& :where(:scope>:first-child){--join-ss:var(--radius-field);--join-se:calc(var(--radius-field) * var(--join-v));--join-es:calc(var(--radius-field) * var(--join-h));--join-ee:0}& :where(:scope>:last-child){--join-ss:0;--join-se:calc(var(--radius-field) * var(--join-h));--join-es:calc(var(--radius-field) * var(--join-v));--join-ee:var(--radius-field)}& :where(:scope>:only-child){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}& :where(:scope>:not(:first-child)){--join-ml:calc(var(--border,1px) * -1 * var(--join-h));--join-mt:calc(var(--border,1px) * -1 * var(--join-v))}}}.join-item{@layer daisyui.l1.l2.l3.l4{&>*{--join-ss:initial;--join-se:initial;--join-es:initial;--join-ee:initial}}border-style:solid;border-width:var(--border,1px);border-start-start-radius:var(--join-ss);border-start-end-radius:var(--join-se);border-end-end-radius:var(--join-ee);border-end-start-radius:var(--join-es);&:not(:disabled,[disabled],.btn-disabled){margin-block-start:var(--join-mt,0);margin-inline-start:var(--join-ml,0)}&:is(:disabled,[disabled],.btn-disabled){border-width:var(--border,1px);border-inline-end-width:calc(var(--border,1px) * var(--join-v));border-block-end-width:calc(var(--border,1px) * var(--join-h))}}.join-vertical{--join-v:1;--join-h:0;flex-direction:column}.join-horizontal{--join-v:0;--join-h:1;flex-direction:row}@media (width>=640px){.sm\:join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;--join-ml:0;--join-mt:0;--join-v:0;--join-h:1;align-items:stretch;display:inline-flex;@scope(&){&>:where(:focus,:has(:focus)){z-index:2}@media (hover:hover){&>:where(.btn:hover,:has(.btn:hover)){z-index:1}}& :where(:scope>:first-child){--join-ss:var(--radius-field);--join-se:calc(var(--radius-field) * var(--join-v));--join-es:calc(var(--radius-field) * var(--join-h));--join-ee:0}& :where(:scope>:last-child){--join-ss:0;--join-se:calc(var(--radius-field) * var(--join-h));--join-es:calc(var(--radius-field) * var(--join-v));--join-ee:var(--radius-field)}& :where(:scope>:only-child){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}& :where(:scope>:not(:first-child)){--join-ml:calc(var(--border,1px) * -1 * var(--join-h));--join-mt:calc(var(--border,1px) * -1 * var(--join-v))}}}.sm\:join-item{@layer daisyui.l1.l2.l3.l4{&>*{--join-ss:initial;--join-se:initial;--join-es:initial;--join-ee:initial}}border-style:solid;border-width:var(--border,1px);border-start-start-radius:var(--join-ss);border-start-end-radius:var(--join-se);border-end-end-radius:var(--join-ee);border-end-start-radius:var(--join-es);&:not(:disabled,[disabled],.btn-disabled){margin-block-start:var(--join-mt,0);margin-inline-start:var(--join-ml,0)}&:is(:disabled,[disabled],.btn-disabled){border-width:var(--border,1px);border-inline-end-width:calc(var(--border,1px) * var(--join-v));border-block-end-width:calc(var(--border,1px) * var(--join-h))}}.sm\:join-vertical{--join-v:1;--join-h:0;flex-direction:column}.sm\:join-horizontal{--join-v:0;--join-h:1;flex-direction:row}}@media (width>=768px){.md\:join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;--join-ml:0;--join-mt:0;--join-v:0;--join-h:1;align-items:stretch;display:inline-flex;@scope(&){&>:where(:focus,:has(:focus)){z-index:2}@media (hover:hover){&>:where(.btn:hover,:has(.btn:hover)){z-index:1}}& :where(:scope>:first-child){--join-ss:var(--radius-field);--join-se:calc(var(--radius-field) * var(--join-v));--join-es:calc(var(--radius-field) * var(--join-h));--join-ee:0}& :where(:scope>:last-child){--join-ss:0;--join-se:calc(var(--radius-field) * var(--join-h));--join-es:calc(var(--radius-field) * var(--join-v));--join-ee:var(--radius-field)}& :where(:scope>:only-child){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}& :where(:scope>:not(:first-child)){--join-ml:calc(var(--border,1px) * -1 * var(--join-h));--join-mt:calc(var(--border,1px) * -1 * var(--join-v))}}}.md\:join-item{@layer daisyui.l1.l2.l3.l4{&>*{--join-ss:initial;--join-se:initial;--join-es:initial;--join-ee:initial}}border-style:solid;border-width:var(--border,1px);border-start-start-radius:var(--join-ss);border-start-end-radius:var(--join-se);border-end-end-radius:var(--join-ee);border-end-start-radius:var(--join-es);&:not(:disabled,[disabled],.btn-disabled){margin-block-start:var(--join-mt,0);margin-inline-start:var(--join-ml,0)}&:is(:disabled,[disabled],.btn-disabled){border-width:var(--border,1px);border-inline-end-width:calc(var(--border,1px) * var(--join-v));border-block-end-width:calc(var(--border,1px) * var(--join-h))}}.md\:join-vertical{--join-v:1;--join-h:0;flex-direction:column}.md\:join-horizontal{--join-v:0;--join-h:1;flex-direction:row}}@media (width>=1024px){.lg\:join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;--join-ml:0;--join-mt:0;--join-v:0;--join-h:1;align-items:stretch;display:inline-flex;@scope(&){&>:where(:focus,:has(:focus)){z-index:2}@media (hover:hover){&>:where(.btn:hover,:has(.btn:hover)){z-index:1}}& :where(:scope>:first-child){--join-ss:var(--radius-field);--join-se:calc(var(--radius-field) * var(--join-v));--join-es:calc(var(--radius-field) * var(--join-h));--join-ee:0}& :where(:scope>:last-child){--join-ss:0;--join-se:calc(var(--radius-field) * var(--join-h));--join-es:calc(var(--radius-field) * var(--join-v));--join-ee:var(--radius-field)}& :where(:scope>:only-child){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}& :where(:scope>:not(:first-child)){--join-ml:calc(var(--border,1px) * -1 * var(--join-h));--join-mt:calc(var(--border,1px) * -1 * var(--join-v))}}}.lg\:join-item{@layer daisyui.l1.l2.l3.l4{&>*{--join-ss:initial;--join-se:initial;--join-es:initial;--join-ee:initial}}border-style:solid;border-width:var(--border,1px);border-start-start-radius:var(--join-ss);border-start-end-radius:var(--join-se);border-end-end-radius:var(--join-ee);border-end-start-radius:var(--join-es);&:not(:disabled,[disabled],.btn-disabled){margin-block-start:var(--join-mt,0);margin-inline-start:var(--join-ml,0)}&:is(:disabled,[disabled],.btn-disabled){border-width:var(--border,1px);border-inline-end-width:calc(var(--border,1px) * var(--join-v));border-block-end-width:calc(var(--border,1px) * var(--join-h))}}.lg\:join-vertical{--join-v:1;--join-h:0;flex-direction:column}.lg\:join-horizontal{--join-v:0;--join-h:1;flex-direction:row}}@media (width>=1280px){.xl\:join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;--join-ml:0;--join-mt:0;--join-v:0;--join-h:1;align-items:stretch;display:inline-flex;@scope(&){&>:where(:focus,:has(:focus)){z-index:2}@media (hover:hover){&>:where(.btn:hover,:has(.btn:hover)){z-index:1}}& :where(:scope>:first-child){--join-ss:var(--radius-field);--join-se:calc(var(--radius-field) * var(--join-v));--join-es:calc(var(--radius-field) * var(--join-h));--join-ee:0}& :where(:scope>:last-child){--join-ss:0;--join-se:calc(var(--radius-field) * var(--join-h));--join-es:calc(var(--radius-field) * var(--join-v));--join-ee:var(--radius-field)}& :where(:scope>:only-child){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}& :where(:scope>:not(:first-child)){--join-ml:calc(var(--border,1px) * -1 * var(--join-h));--join-mt:calc(var(--border,1px) * -1 * var(--join-v))}}}.xl\:join-item{@layer daisyui.l1.l2.l3.l4{&>*{--join-ss:initial;--join-se:initial;--join-es:initial;--join-ee:initial}}border-style:solid;border-width:var(--border,1px);border-start-start-radius:var(--join-ss);border-start-end-radius:var(--join-se);border-end-end-radius:var(--join-ee);border-end-start-radius:var(--join-es);&:not(:disabled,[disabled],.btn-disabled){margin-block-start:var(--join-mt,0);margin-inline-start:var(--join-ml,0)}&:is(:disabled,[disabled],.btn-disabled){border-width:var(--border,1px);border-inline-end-width:calc(var(--border,1px) * var(--join-v));border-block-end-width:calc(var(--border,1px) * var(--join-h))}}.xl\:join-vertical{--join-v:1;--join-h:0;flex-direction:column}.xl\:join-horizontal{--join-v:0;--join-h:1;flex-direction:row}}@media (width>=1536px){.\32 xl\:join{--join-ss:0;--join-se:0;--join-es:0;--join-ee:0;--join-ml:0;--join-mt:0;--join-v:0;--join-h:1;align-items:stretch;display:inline-flex;@scope(&){&>:where(:focus,:has(:focus)){z-index:2}@media (hover:hover){&>:where(.btn:hover,:has(.btn:hover)){z-index:1}}& :where(:scope>:first-child){--join-ss:var(--radius-field);--join-se:calc(var(--radius-field) * var(--join-v));--join-es:calc(var(--radius-field) * var(--join-h));--join-ee:0}& :where(:scope>:last-child){--join-ss:0;--join-se:calc(var(--radius-field) * var(--join-h));--join-es:calc(var(--radius-field) * var(--join-v));--join-ee:var(--radius-field)}& :where(:scope>:only-child){--join-ss:var(--radius-field);--join-se:var(--radius-field);--join-es:var(--radius-field);--join-ee:var(--radius-field)}& :where(:scope>:not(:first-child)){--join-ml:calc(var(--border,1px) * -1 * var(--join-h));--join-mt:calc(var(--border,1px) * -1 * var(--join-v))}}}.\32 xl\:join-item{@layer daisyui.l1.l2.l3.l4{&>*{--join-ss:initial;--join-se:initial;--join-es:initial;--join-ee:initial}}border-style:solid;border-width:var(--border,1px);border-start-start-radius:var(--join-ss);border-start-end-radius:var(--join-se);border-end-end-radius:var(--join-ee);border-end-start-radius:var(--join-es);&:not(:disabled,[disabled],.btn-disabled){margin-block-start:var(--join-mt,0);margin-inline-start:var(--join-ml,0)}&:is(:disabled,[disabled],.btn-disabled){border-width:var(--border,1px);border-inline-end-width:calc(var(--border,1px) * var(--join-v));border-block-end-width:calc(var(--border,1px) * var(--join-h))}}.\32 xl\:join-vertical{--join-v:1;--join-h:0;flex-direction:column}.\32 xl\:join-horizontal{--join-v:0;--join-h:1;flex-direction:row}}.rounded-box{border-radius:var(--radius-box)}.rounded-field{border-radius:var(--radius-field)}.rounded-selector{border-radius:var(--radius-selector)}.rounded-t-box{border-top-left-radius:var(--radius-box);border-top-right-radius:var(--radius-box)}.rounded-b-box{border-bottom-left-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.rounded-l-box{border-top-left-radius:var(--radius-box);border-bottom-left-radius:var(--radius-box)}.rounded-r-box{border-top-right-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.rounded-tl-box{border-top-left-radius:var(--radius-box)}.rounded-tr-box{border-top-right-radius:var(--radius-box)}.rounded-br-box{border-bottom-right-radius:var(--radius-box)}.rounded-bl-box{border-bottom-left-radius:var(--radius-box)}.rounded-t-field{border-top-left-radius:var(--radius-field);border-top-right-radius:var(--radius-field)}.rounded-b-field{border-bottom-left-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.rounded-l-field{border-top-left-radius:var(--radius-field);border-bottom-left-radius:var(--radius-field)}.rounded-r-field{border-top-right-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.rounded-tl-field{border-top-left-radius:var(--radius-field)}.rounded-tr-field{border-top-right-radius:var(--radius-field)}.rounded-br-field{border-bottom-right-radius:var(--radius-field)}.rounded-bl-field{border-bottom-left-radius:var(--radius-field)}.rounded-t-selector{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector)}.rounded-b-selector{border-bottom-left-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.rounded-l-selector{border-top-left-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.rounded-r-selector{border-top-right-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.rounded-tl-selector{border-top-left-radius:var(--radius-selector)}.rounded-tr-selector{border-top-right-radius:var(--radius-selector)}.rounded-br-selector{border-bottom-right-radius:var(--radius-selector)}.rounded-bl-selector{border-bottom-left-radius:var(--radius-selector)}@media (width>=640px){.sm\:rounded-box{border-radius:var(--radius-box)}.sm\:rounded-field{border-radius:var(--radius-field)}.sm\:rounded-selector{border-radius:var(--radius-selector)}.sm\:rounded-t-box{border-top-left-radius:var(--radius-box);border-top-right-radius:var(--radius-box)}.sm\:rounded-b-box{border-bottom-left-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.sm\:rounded-l-box{border-top-left-radius:var(--radius-box);border-bottom-left-radius:var(--radius-box)}.sm\:rounded-r-box{border-top-right-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.sm\:rounded-tl-box{border-top-left-radius:var(--radius-box)}.sm\:rounded-tr-box{border-top-right-radius:var(--radius-box)}.sm\:rounded-br-box{border-bottom-right-radius:var(--radius-box)}.sm\:rounded-bl-box{border-bottom-left-radius:var(--radius-box)}.sm\:rounded-t-field{border-top-left-radius:var(--radius-field);border-top-right-radius:var(--radius-field)}.sm\:rounded-b-field{border-bottom-left-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.sm\:rounded-l-field{border-top-left-radius:var(--radius-field);border-bottom-left-radius:var(--radius-field)}.sm\:rounded-r-field{border-top-right-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.sm\:rounded-tl-field{border-top-left-radius:var(--radius-field)}.sm\:rounded-tr-field{border-top-right-radius:var(--radius-field)}.sm\:rounded-br-field{border-bottom-right-radius:var(--radius-field)}.sm\:rounded-bl-field{border-bottom-left-radius:var(--radius-field)}.sm\:rounded-t-selector{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector)}.sm\:rounded-b-selector{border-bottom-left-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.sm\:rounded-l-selector{border-top-left-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.sm\:rounded-r-selector{border-top-right-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.sm\:rounded-tl-selector{border-top-left-radius:var(--radius-selector)}.sm\:rounded-tr-selector{border-top-right-radius:var(--radius-selector)}.sm\:rounded-br-selector{border-bottom-right-radius:var(--radius-selector)}.sm\:rounded-bl-selector{border-bottom-left-radius:var(--radius-selector)}}@media (width>=768px){.md\:rounded-box{border-radius:var(--radius-box)}.md\:rounded-field{border-radius:var(--radius-field)}.md\:rounded-selector{border-radius:var(--radius-selector)}.md\:rounded-t-box{border-top-left-radius:var(--radius-box);border-top-right-radius:var(--radius-box)}.md\:rounded-b-box{border-bottom-left-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.md\:rounded-l-box{border-top-left-radius:var(--radius-box);border-bottom-left-radius:var(--radius-box)}.md\:rounded-r-box{border-top-right-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.md\:rounded-tl-box{border-top-left-radius:var(--radius-box)}.md\:rounded-tr-box{border-top-right-radius:var(--radius-box)}.md\:rounded-br-box{border-bottom-right-radius:var(--radius-box)}.md\:rounded-bl-box{border-bottom-left-radius:var(--radius-box)}.md\:rounded-t-field{border-top-left-radius:var(--radius-field);border-top-right-radius:var(--radius-field)}.md\:rounded-b-field{border-bottom-left-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.md\:rounded-l-field{border-top-left-radius:var(--radius-field);border-bottom-left-radius:var(--radius-field)}.md\:rounded-r-field{border-top-right-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.md\:rounded-tl-field{border-top-left-radius:var(--radius-field)}.md\:rounded-tr-field{border-top-right-radius:var(--radius-field)}.md\:rounded-br-field{border-bottom-right-radius:var(--radius-field)}.md\:rounded-bl-field{border-bottom-left-radius:var(--radius-field)}.md\:rounded-t-selector{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector)}.md\:rounded-b-selector{border-bottom-left-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.md\:rounded-l-selector{border-top-left-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.md\:rounded-r-selector{border-top-right-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.md\:rounded-tl-selector{border-top-left-radius:var(--radius-selector)}.md\:rounded-tr-selector{border-top-right-radius:var(--radius-selector)}.md\:rounded-br-selector{border-bottom-right-radius:var(--radius-selector)}.md\:rounded-bl-selector{border-bottom-left-radius:var(--radius-selector)}}@media (width>=1024px){.lg\:rounded-box{border-radius:var(--radius-box)}.lg\:rounded-field{border-radius:var(--radius-field)}.lg\:rounded-selector{border-radius:var(--radius-selector)}.lg\:rounded-t-box{border-top-left-radius:var(--radius-box);border-top-right-radius:var(--radius-box)}.lg\:rounded-b-box{border-bottom-left-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.lg\:rounded-l-box{border-top-left-radius:var(--radius-box);border-bottom-left-radius:var(--radius-box)}.lg\:rounded-r-box{border-top-right-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.lg\:rounded-tl-box{border-top-left-radius:var(--radius-box)}.lg\:rounded-tr-box{border-top-right-radius:var(--radius-box)}.lg\:rounded-br-box{border-bottom-right-radius:var(--radius-box)}.lg\:rounded-bl-box{border-bottom-left-radius:var(--radius-box)}.lg\:rounded-t-field{border-top-left-radius:var(--radius-field);border-top-right-radius:var(--radius-field)}.lg\:rounded-b-field{border-bottom-left-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.lg\:rounded-l-field{border-top-left-radius:var(--radius-field);border-bottom-left-radius:var(--radius-field)}.lg\:rounded-r-field{border-top-right-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.lg\:rounded-tl-field{border-top-left-radius:var(--radius-field)}.lg\:rounded-tr-field{border-top-right-radius:var(--radius-field)}.lg\:rounded-br-field{border-bottom-right-radius:var(--radius-field)}.lg\:rounded-bl-field{border-bottom-left-radius:var(--radius-field)}.lg\:rounded-t-selector{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector)}.lg\:rounded-b-selector{border-bottom-left-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.lg\:rounded-l-selector{border-top-left-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.lg\:rounded-r-selector{border-top-right-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.lg\:rounded-tl-selector{border-top-left-radius:var(--radius-selector)}.lg\:rounded-tr-selector{border-top-right-radius:var(--radius-selector)}.lg\:rounded-br-selector{border-bottom-right-radius:var(--radius-selector)}.lg\:rounded-bl-selector{border-bottom-left-radius:var(--radius-selector)}}@media (width>=1280px){.xl\:rounded-box{border-radius:var(--radius-box)}.xl\:rounded-field{border-radius:var(--radius-field)}.xl\:rounded-selector{border-radius:var(--radius-selector)}.xl\:rounded-t-box{border-top-left-radius:var(--radius-box);border-top-right-radius:var(--radius-box)}.xl\:rounded-b-box{border-bottom-left-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.xl\:rounded-l-box{border-top-left-radius:var(--radius-box);border-bottom-left-radius:var(--radius-box)}.xl\:rounded-r-box{border-top-right-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.xl\:rounded-tl-box{border-top-left-radius:var(--radius-box)}.xl\:rounded-tr-box{border-top-right-radius:var(--radius-box)}.xl\:rounded-br-box{border-bottom-right-radius:var(--radius-box)}.xl\:rounded-bl-box{border-bottom-left-radius:var(--radius-box)}.xl\:rounded-t-field{border-top-left-radius:var(--radius-field);border-top-right-radius:var(--radius-field)}.xl\:rounded-b-field{border-bottom-left-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.xl\:rounded-l-field{border-top-left-radius:var(--radius-field);border-bottom-left-radius:var(--radius-field)}.xl\:rounded-r-field{border-top-right-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.xl\:rounded-tl-field{border-top-left-radius:var(--radius-field)}.xl\:rounded-tr-field{border-top-right-radius:var(--radius-field)}.xl\:rounded-br-field{border-bottom-right-radius:var(--radius-field)}.xl\:rounded-bl-field{border-bottom-left-radius:var(--radius-field)}.xl\:rounded-t-selector{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector)}.xl\:rounded-b-selector{border-bottom-left-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.xl\:rounded-l-selector{border-top-left-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.xl\:rounded-r-selector{border-top-right-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.xl\:rounded-tl-selector{border-top-left-radius:var(--radius-selector)}.xl\:rounded-tr-selector{border-top-right-radius:var(--radius-selector)}.xl\:rounded-br-selector{border-bottom-right-radius:var(--radius-selector)}.xl\:rounded-bl-selector{border-bottom-left-radius:var(--radius-selector)}}@media (width>=1536px){.\32 xl\:rounded-box{border-radius:var(--radius-box)}.\32 xl\:rounded-field{border-radius:var(--radius-field)}.\32 xl\:rounded-selector{border-radius:var(--radius-selector)}.\32 xl\:rounded-t-box{border-top-left-radius:var(--radius-box);border-top-right-radius:var(--radius-box)}.\32 xl\:rounded-b-box{border-bottom-left-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.\32 xl\:rounded-l-box{border-top-left-radius:var(--radius-box);border-bottom-left-radius:var(--radius-box)}.\32 xl\:rounded-r-box{border-top-right-radius:var(--radius-box);border-bottom-right-radius:var(--radius-box)}.\32 xl\:rounded-tl-box{border-top-left-radius:var(--radius-box)}.\32 xl\:rounded-tr-box{border-top-right-radius:var(--radius-box)}.\32 xl\:rounded-br-box{border-bottom-right-radius:var(--radius-box)}.\32 xl\:rounded-bl-box{border-bottom-left-radius:var(--radius-box)}.\32 xl\:rounded-t-field{border-top-left-radius:var(--radius-field);border-top-right-radius:var(--radius-field)}.\32 xl\:rounded-b-field{border-bottom-left-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.\32 xl\:rounded-l-field{border-top-left-radius:var(--radius-field);border-bottom-left-radius:var(--radius-field)}.\32 xl\:rounded-r-field{border-top-right-radius:var(--radius-field);border-bottom-right-radius:var(--radius-field)}.\32 xl\:rounded-tl-field{border-top-left-radius:var(--radius-field)}.\32 xl\:rounded-tr-field{border-top-right-radius:var(--radius-field)}.\32 xl\:rounded-br-field{border-bottom-right-radius:var(--radius-field)}.\32 xl\:rounded-bl-field{border-bottom-left-radius:var(--radius-field)}.\32 xl\:rounded-t-selector{border-top-left-radius:var(--radius-selector);border-top-right-radius:var(--radius-selector)}.\32 xl\:rounded-b-selector{border-bottom-left-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.\32 xl\:rounded-l-selector{border-top-left-radius:var(--radius-selector);border-bottom-left-radius:var(--radius-selector)}.\32 xl\:rounded-r-selector{border-top-right-radius:var(--radius-selector);border-bottom-right-radius:var(--radius-selector)}.\32 xl\:rounded-tl-selector{border-top-left-radius:var(--radius-selector)}.\32 xl\:rounded-tr-selector{border-top-right-radius:var(--radius-selector)}.\32 xl\:rounded-br-selector{border-bottom-right-radius:var(--radius-selector)}.\32 xl\:rounded-bl-selector{border-bottom-left-radius:var(--radius-selector)}}:root .prose{--tw-prose-body:color-mix(in oklab, var(--color-base-content) 80%, #0000);--tw-prose-headings:var(--color-base-content);--tw-prose-lead:var(--color-base-content);--tw-prose-links:var(--color-base-content);--tw-prose-bold:var(--color-base-content);--tw-prose-counters:var(--color-base-content);--tw-prose-bullets:color-mix(in oklab, var(--color-base-content) 50%, #0000);--tw-prose-hr:color-mix(in oklab, var(--color-base-content) 20%, #0000);--tw-prose-quotes:var(--color-base-content);--tw-prose-quote-borders:color-mix(in oklab, var(--color-base-content) 20%, #0000);--tw-prose-captions:color-mix(in oklab, var(--color-base-content) 50%, #0000);--tw-prose-code:var(--color-base-content);--tw-prose-pre-code:var(--color-neutral-content);--tw-prose-pre-bg:var(--color-neutral);--tw-prose-th-borders:color-mix(in oklab, var(--color-base-content) 50%, #0000);--tw-prose-td-borders:color-mix(in oklab, var(--color-base-content) 20%, #0000);--tw-prose-kbd:color-mix(in oklab, var(--color-base-content) 80%, #0000);& :where(code):not(pre>code){background-color:var(--color-base-200);border-radius:var(--radius-selector);border:var(--border) solid var(--color-base-300);font-weight:inherit;padding-block:.2em;padding-inline:.5em;&:before,&:after{display:none}}}@media (width>=40rem){.sm\:bg-base-100{background-color:var(--color-base-100)}.sm\:text-base-100{color:var(--color-base-100)}.sm\:border-base-100{border-color:var(--color-base-100)}.sm\:bg-base-200{background-color:var(--color-base-200)}.sm\:text-base-200{color:var(--color-base-200)}.sm\:border-base-200{border-color:var(--color-base-200)}.sm\:bg-base-300{background-color:var(--color-base-300)}.sm\:text-base-300{color:var(--color-base-300)}.sm\:border-base-300{border-color:var(--color-base-300)}.sm\:bg-base-content{background-color:var(--color-base-content)}.sm\:text-base-content{color:var(--color-base-content)}.sm\:border-base-content{border-color:var(--color-base-content)}.sm\:bg-primary{background-color:var(--color-primary)}.sm\:text-primary{color:var(--color-primary)}.sm\:border-primary{border-color:var(--color-primary)}.sm\:bg-primary-content{background-color:var(--color-primary-content)}.sm\:text-primary-content{color:var(--color-primary-content)}.sm\:border-primary-content{border-color:var(--color-primary-content)}.sm\:bg-secondary{background-color:var(--color-secondary)}.sm\:text-secondary{color:var(--color-secondary)}.sm\:border-secondary{border-color:var(--color-secondary)}.sm\:bg-secondary-content{background-color:var(--color-secondary-content)}.sm\:text-secondary-content{color:var(--color-secondary-content)}.sm\:border-secondary-content{border-color:var(--color-secondary-content)}.sm\:bg-accent{background-color:var(--color-accent)}.sm\:text-accent{color:var(--color-accent)}.sm\:border-accent{border-color:var(--color-accent)}.sm\:bg-accent-content{background-color:var(--color-accent-content)}.sm\:text-accent-content{color:var(--color-accent-content)}.sm\:border-accent-content{border-color:var(--color-accent-content)}.sm\:bg-neutral{background-color:var(--color-neutral)}.sm\:text-neutral{color:var(--color-neutral)}.sm\:border-neutral{border-color:var(--color-neutral)}.sm\:bg-neutral-content{background-color:var(--color-neutral-content)}.sm\:text-neutral-content{color:var(--color-neutral-content)}.sm\:border-neutral-content{border-color:var(--color-neutral-content)}.sm\:bg-info{background-color:var(--color-info)}.sm\:text-info{color:var(--color-info)}.sm\:border-info{border-color:var(--color-info)}.sm\:bg-info-content{background-color:var(--color-info-content)}.sm\:text-info-content{color:var(--color-info-content)}.sm\:border-info-content{border-color:var(--color-info-content)}.sm\:bg-success{background-color:var(--color-success)}.sm\:text-success{color:var(--color-success)}.sm\:border-success{border-color:var(--color-success)}.sm\:bg-success-content{background-color:var(--color-success-content)}.sm\:text-success-content{color:var(--color-success-content)}.sm\:border-success-content{border-color:var(--color-success-content)}.sm\:bg-warning{background-color:var(--color-warning)}.sm\:text-warning{color:var(--color-warning)}.sm\:border-warning{border-color:var(--color-warning)}.sm\:bg-warning-content{background-color:var(--color-warning-content)}.sm\:text-warning-content{color:var(--color-warning-content)}.sm\:border-warning-content{border-color:var(--color-warning-content)}.sm\:bg-error{background-color:var(--color-error)}.sm\:text-error{color:var(--color-error)}.sm\:border-error{border-color:var(--color-error)}.sm\:bg-error-content{background-color:var(--color-error-content)}.sm\:text-error-content{color:var(--color-error-content)}.sm\:border-error-content{border-color:var(--color-error-content)}}@media (width>=48rem){.md\:bg-base-100{background-color:var(--color-base-100)}.md\:text-base-100{color:var(--color-base-100)}.md\:border-base-100{border-color:var(--color-base-100)}.md\:bg-base-200{background-color:var(--color-base-200)}.md\:text-base-200{color:var(--color-base-200)}.md\:border-base-200{border-color:var(--color-base-200)}.md\:bg-base-300{background-color:var(--color-base-300)}.md\:text-base-300{color:var(--color-base-300)}.md\:border-base-300{border-color:var(--color-base-300)}.md\:bg-base-content{background-color:var(--color-base-content)}.md\:text-base-content{color:var(--color-base-content)}.md\:border-base-content{border-color:var(--color-base-content)}.md\:bg-primary{background-color:var(--color-primary)}.md\:text-primary{color:var(--color-primary)}.md\:border-primary{border-color:var(--color-primary)}.md\:bg-primary-content{background-color:var(--color-primary-content)}.md\:text-primary-content{color:var(--color-primary-content)}.md\:border-primary-content{border-color:var(--color-primary-content)}.md\:bg-secondary{background-color:var(--color-secondary)}.md\:text-secondary{color:var(--color-secondary)}.md\:border-secondary{border-color:var(--color-secondary)}.md\:bg-secondary-content{background-color:var(--color-secondary-content)}.md\:text-secondary-content{color:var(--color-secondary-content)}.md\:border-secondary-content{border-color:var(--color-secondary-content)}.md\:bg-accent{background-color:var(--color-accent)}.md\:text-accent{color:var(--color-accent)}.md\:border-accent{border-color:var(--color-accent)}.md\:bg-accent-content{background-color:var(--color-accent-content)}.md\:text-accent-content{color:var(--color-accent-content)}.md\:border-accent-content{border-color:var(--color-accent-content)}.md\:bg-neutral{background-color:var(--color-neutral)}.md\:text-neutral{color:var(--color-neutral)}.md\:border-neutral{border-color:var(--color-neutral)}.md\:bg-neutral-content{background-color:var(--color-neutral-content)}.md\:text-neutral-content{color:var(--color-neutral-content)}.md\:border-neutral-content{border-color:var(--color-neutral-content)}.md\:bg-info{background-color:var(--color-info)}.md\:text-info{color:var(--color-info)}.md\:border-info{border-color:var(--color-info)}.md\:bg-info-content{background-color:var(--color-info-content)}.md\:text-info-content{color:var(--color-info-content)}.md\:border-info-content{border-color:var(--color-info-content)}.md\:bg-success{background-color:var(--color-success)}.md\:text-success{color:var(--color-success)}.md\:border-success{border-color:var(--color-success)}.md\:bg-success-content{background-color:var(--color-success-content)}.md\:text-success-content{color:var(--color-success-content)}.md\:border-success-content{border-color:var(--color-success-content)}.md\:bg-warning{background-color:var(--color-warning)}.md\:text-warning{color:var(--color-warning)}.md\:border-warning{border-color:var(--color-warning)}.md\:bg-warning-content{background-color:var(--color-warning-content)}.md\:text-warning-content{color:var(--color-warning-content)}.md\:border-warning-content{border-color:var(--color-warning-content)}.md\:bg-error{background-color:var(--color-error)}.md\:text-error{color:var(--color-error)}.md\:border-error{border-color:var(--color-error)}.md\:bg-error-content{background-color:var(--color-error-content)}.md\:text-error-content{color:var(--color-error-content)}.md\:border-error-content{border-color:var(--color-error-content)}}@media (width>=64rem){.lg\:bg-base-100{background-color:var(--color-base-100)}.lg\:text-base-100{color:var(--color-base-100)}.lg\:border-base-100{border-color:var(--color-base-100)}.lg\:bg-base-200{background-color:var(--color-base-200)}.lg\:text-base-200{color:var(--color-base-200)}.lg\:border-base-200{border-color:var(--color-base-200)}.lg\:bg-base-300{background-color:var(--color-base-300)}.lg\:text-base-300{color:var(--color-base-300)}.lg\:border-base-300{border-color:var(--color-base-300)}.lg\:bg-base-content{background-color:var(--color-base-content)}.lg\:text-base-content{color:var(--color-base-content)}.lg\:border-base-content{border-color:var(--color-base-content)}.lg\:bg-primary{background-color:var(--color-primary)}.lg\:text-primary{color:var(--color-primary)}.lg\:border-primary{border-color:var(--color-primary)}.lg\:bg-primary-content{background-color:var(--color-primary-content)}.lg\:text-primary-content{color:var(--color-primary-content)}.lg\:border-primary-content{border-color:var(--color-primary-content)}.lg\:bg-secondary{background-color:var(--color-secondary)}.lg\:text-secondary{color:var(--color-secondary)}.lg\:border-secondary{border-color:var(--color-secondary)}.lg\:bg-secondary-content{background-color:var(--color-secondary-content)}.lg\:text-secondary-content{color:var(--color-secondary-content)}.lg\:border-secondary-content{border-color:var(--color-secondary-content)}.lg\:bg-accent{background-color:var(--color-accent)}.lg\:text-accent{color:var(--color-accent)}.lg\:border-accent{border-color:var(--color-accent)}.lg\:bg-accent-content{background-color:var(--color-accent-content)}.lg\:text-accent-content{color:var(--color-accent-content)}.lg\:border-accent-content{border-color:var(--color-accent-content)}.lg\:bg-neutral{background-color:var(--color-neutral)}.lg\:text-neutral{color:var(--color-neutral)}.lg\:border-neutral{border-color:var(--color-neutral)}.lg\:bg-neutral-content{background-color:var(--color-neutral-content)}.lg\:text-neutral-content{color:var(--color-neutral-content)}.lg\:border-neutral-content{border-color:var(--color-neutral-content)}.lg\:bg-info{background-color:var(--color-info)}.lg\:text-info{color:var(--color-info)}.lg\:border-info{border-color:var(--color-info)}.lg\:bg-info-content{background-color:var(--color-info-content)}.lg\:text-info-content{color:var(--color-info-content)}.lg\:border-info-content{border-color:var(--color-info-content)}.lg\:bg-success{background-color:var(--color-success)}.lg\:text-success{color:var(--color-success)}.lg\:border-success{border-color:var(--color-success)}.lg\:bg-success-content{background-color:var(--color-success-content)}.lg\:text-success-content{color:var(--color-success-content)}.lg\:border-success-content{border-color:var(--color-success-content)}.lg\:bg-warning{background-color:var(--color-warning)}.lg\:text-warning{color:var(--color-warning)}.lg\:border-warning{border-color:var(--color-warning)}.lg\:bg-warning-content{background-color:var(--color-warning-content)}.lg\:text-warning-content{color:var(--color-warning-content)}.lg\:border-warning-content{border-color:var(--color-warning-content)}.lg\:bg-error{background-color:var(--color-error)}.lg\:text-error{color:var(--color-error)}.lg\:border-error{border-color:var(--color-error)}.lg\:bg-error-content{background-color:var(--color-error-content)}.lg\:text-error-content{color:var(--color-error-content)}.lg\:border-error-content{border-color:var(--color-error-content)}}@media (width>=80rem){.xl\:bg-base-100{background-color:var(--color-base-100)}.xl\:text-base-100{color:var(--color-base-100)}.xl\:border-base-100{border-color:var(--color-base-100)}.xl\:bg-base-200{background-color:var(--color-base-200)}.xl\:text-base-200{color:var(--color-base-200)}.xl\:border-base-200{border-color:var(--color-base-200)}.xl\:bg-base-300{background-color:var(--color-base-300)}.xl\:text-base-300{color:var(--color-base-300)}.xl\:border-base-300{border-color:var(--color-base-300)}.xl\:bg-base-content{background-color:var(--color-base-content)}.xl\:text-base-content{color:var(--color-base-content)}.xl\:border-base-content{border-color:var(--color-base-content)}.xl\:bg-primary{background-color:var(--color-primary)}.xl\:text-primary{color:var(--color-primary)}.xl\:border-primary{border-color:var(--color-primary)}.xl\:bg-primary-content{background-color:var(--color-primary-content)}.xl\:text-primary-content{color:var(--color-primary-content)}.xl\:border-primary-content{border-color:var(--color-primary-content)}.xl\:bg-secondary{background-color:var(--color-secondary)}.xl\:text-secondary{color:var(--color-secondary)}.xl\:border-secondary{border-color:var(--color-secondary)}.xl\:bg-secondary-content{background-color:var(--color-secondary-content)}.xl\:text-secondary-content{color:var(--color-secondary-content)}.xl\:border-secondary-content{border-color:var(--color-secondary-content)}.xl\:bg-accent{background-color:var(--color-accent)}.xl\:text-accent{color:var(--color-accent)}.xl\:border-accent{border-color:var(--color-accent)}.xl\:bg-accent-content{background-color:var(--color-accent-content)}.xl\:text-accent-content{color:var(--color-accent-content)}.xl\:border-accent-content{border-color:var(--color-accent-content)}.xl\:bg-neutral{background-color:var(--color-neutral)}.xl\:text-neutral{color:var(--color-neutral)}.xl\:border-neutral{border-color:var(--color-neutral)}.xl\:bg-neutral-content{background-color:var(--color-neutral-content)}.xl\:text-neutral-content{color:var(--color-neutral-content)}.xl\:border-neutral-content{border-color:var(--color-neutral-content)}.xl\:bg-info{background-color:var(--color-info)}.xl\:text-info{color:var(--color-info)}.xl\:border-info{border-color:var(--color-info)}.xl\:bg-info-content{background-color:var(--color-info-content)}.xl\:text-info-content{color:var(--color-info-content)}.xl\:border-info-content{border-color:var(--color-info-content)}.xl\:bg-success{background-color:var(--color-success)}.xl\:text-success{color:var(--color-success)}.xl\:border-success{border-color:var(--color-success)}.xl\:bg-success-content{background-color:var(--color-success-content)}.xl\:text-success-content{color:var(--color-success-content)}.xl\:border-success-content{border-color:var(--color-success-content)}.xl\:bg-warning{background-color:var(--color-warning)}.xl\:text-warning{color:var(--color-warning)}.xl\:border-warning{border-color:var(--color-warning)}.xl\:bg-warning-content{background-color:var(--color-warning-content)}.xl\:text-warning-content{color:var(--color-warning-content)}.xl\:border-warning-content{border-color:var(--color-warning-content)}.xl\:bg-error{background-color:var(--color-error)}.xl\:text-error{color:var(--color-error)}.xl\:border-error{border-color:var(--color-error)}.xl\:bg-error-content{background-color:var(--color-error-content)}.xl\:text-error-content{color:var(--color-error-content)}.xl\:border-error-content{border-color:var(--color-error-content)}}@media (width>=96rem){.\32 xl\:bg-base-100{background-color:var(--color-base-100)}.\32 xl\:text-base-100{color:var(--color-base-100)}.\32 xl\:border-base-100{border-color:var(--color-base-100)}.\32 xl\:bg-base-200{background-color:var(--color-base-200)}.\32 xl\:text-base-200{color:var(--color-base-200)}.\32 xl\:border-base-200{border-color:var(--color-base-200)}.\32 xl\:bg-base-300{background-color:var(--color-base-300)}.\32 xl\:text-base-300{color:var(--color-base-300)}.\32 xl\:border-base-300{border-color:var(--color-base-300)}.\32 xl\:bg-base-content{background-color:var(--color-base-content)}.\32 xl\:text-base-content{color:var(--color-base-content)}.\32 xl\:border-base-content{border-color:var(--color-base-content)}.\32 xl\:bg-primary{background-color:var(--color-primary)}.\32 xl\:text-primary{color:var(--color-primary)}.\32 xl\:border-primary{border-color:var(--color-primary)}.\32 xl\:bg-primary-content{background-color:var(--color-primary-content)}.\32 xl\:text-primary-content{color:var(--color-primary-content)}.\32 xl\:border-primary-content{border-color:var(--color-primary-content)}.\32 xl\:bg-secondary{background-color:var(--color-secondary)}.\32 xl\:text-secondary{color:var(--color-secondary)}.\32 xl\:border-secondary{border-color:var(--color-secondary)}.\32 xl\:bg-secondary-content{background-color:var(--color-secondary-content)}.\32 xl\:text-secondary-content{color:var(--color-secondary-content)}.\32 xl\:border-secondary-content{border-color:var(--color-secondary-content)}.\32 xl\:bg-accent{background-color:var(--color-accent)}.\32 xl\:text-accent{color:var(--color-accent)}.\32 xl\:border-accent{border-color:var(--color-accent)}.\32 xl\:bg-accent-content{background-color:var(--color-accent-content)}.\32 xl\:text-accent-content{color:var(--color-accent-content)}.\32 xl\:border-accent-content{border-color:var(--color-accent-content)}.\32 xl\:bg-neutral{background-color:var(--color-neutral)}.\32 xl\:text-neutral{color:var(--color-neutral)}.\32 xl\:border-neutral{border-color:var(--color-neutral)}.\32 xl\:bg-neutral-content{background-color:var(--color-neutral-content)}.\32 xl\:text-neutral-content{color:var(--color-neutral-content)}.\32 xl\:border-neutral-content{border-color:var(--color-neutral-content)}.\32 xl\:bg-info{background-color:var(--color-info)}.\32 xl\:text-info{color:var(--color-info)}.\32 xl\:border-info{border-color:var(--color-info)}.\32 xl\:bg-info-content{background-color:var(--color-info-content)}.\32 xl\:text-info-content{color:var(--color-info-content)}.\32 xl\:border-info-content{border-color:var(--color-info-content)}.\32 xl\:bg-success{background-color:var(--color-success)}.\32 xl\:text-success{color:var(--color-success)}.\32 xl\:border-success{border-color:var(--color-success)}.\32 xl\:bg-success-content{background-color:var(--color-success-content)}.\32 xl\:text-success-content{color:var(--color-success-content)}.\32 xl\:border-success-content{border-color:var(--color-success-content)}.\32 xl\:bg-warning{background-color:var(--color-warning)}.\32 xl\:text-warning{color:var(--color-warning)}.\32 xl\:border-warning{border-color:var(--color-warning)}.\32 xl\:bg-warning-content{background-color:var(--color-warning-content)}.\32 xl\:text-warning-content{color:var(--color-warning-content)}.\32 xl\:border-warning-content{border-color:var(--color-warning-content)}.\32 xl\:bg-error{background-color:var(--color-error)}.\32 xl\:text-error{color:var(--color-error)}.\32 xl\:border-error{border-color:var(--color-error)}.\32 xl\:bg-error-content{background-color:var(--color-error-content)}.\32 xl\:text-error-content{color:var(--color-error-content)}.\32 xl\:border-error-content{border-color:var(--color-error-content)}}.bg-base-100{background-color:var(--color-base-100)}.bg-base-100\/10{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)10%,#0000)}}.bg-base-100\/20{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)20%,#0000)}}.bg-base-100\/30{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)30%,#0000)}}.bg-base-100\/40{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)40%,#0000)}}.bg-base-100\/50{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)50%,#0000)}}.bg-base-100\/60{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)60%,#0000)}}.bg-base-100\/70{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)70%,#0000)}}.bg-base-100\/80{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)80%,#0000)}}.bg-base-100\/90{background-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-100)90%,#0000)}}.text-base-100{color:var(--color-base-100)}.text-base-100\/10{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)10%,#0000)}}.text-base-100\/20{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)20%,#0000)}}.text-base-100\/30{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)30%,#0000)}}.text-base-100\/40{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)40%,#0000)}}.text-base-100\/50{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)50%,#0000)}}.text-base-100\/60{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)60%,#0000)}}.text-base-100\/70{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)70%,#0000)}}.text-base-100\/80{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)80%,#0000)}}.text-base-100\/90{color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-100)90%,#0000)}}.border-base-100{border-color:var(--color-base-100)}.border-base-100\/10{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)10%,#0000)}}.border-base-100\/20{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)20%,#0000)}}.border-base-100\/30{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)30%,#0000)}}.border-base-100\/40{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)40%,#0000)}}.border-base-100\/50{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)50%,#0000)}}.border-base-100\/60{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)60%,#0000)}}.border-base-100\/70{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)70%,#0000)}}.border-base-100\/80{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)80%,#0000)}}.border-base-100\/90{border-color:var(--color-base-100);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-100)90%,#0000)}}.bg-base-200{background-color:var(--color-base-200)}.bg-base-200\/10{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)10%,#0000)}}.bg-base-200\/20{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)20%,#0000)}}.bg-base-200\/30{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)30%,#0000)}}.bg-base-200\/40{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)40%,#0000)}}.bg-base-200\/50{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)50%,#0000)}}.bg-base-200\/60{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)60%,#0000)}}.bg-base-200\/70{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)70%,#0000)}}.bg-base-200\/80{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)80%,#0000)}}.bg-base-200\/90{background-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-200)90%,#0000)}}.text-base-200{color:var(--color-base-200)}.text-base-200\/10{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)10%,#0000)}}.text-base-200\/20{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)20%,#0000)}}.text-base-200\/30{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)30%,#0000)}}.text-base-200\/40{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)40%,#0000)}}.text-base-200\/50{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)50%,#0000)}}.text-base-200\/60{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)60%,#0000)}}.text-base-200\/70{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)70%,#0000)}}.text-base-200\/80{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)80%,#0000)}}.text-base-200\/90{color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-200)90%,#0000)}}.border-base-200{border-color:var(--color-base-200)}.border-base-200\/10{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)10%,#0000)}}.border-base-200\/20{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)20%,#0000)}}.border-base-200\/30{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)30%,#0000)}}.border-base-200\/40{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)40%,#0000)}}.border-base-200\/50{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)50%,#0000)}}.border-base-200\/60{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)60%,#0000)}}.border-base-200\/70{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)70%,#0000)}}.border-base-200\/80{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)80%,#0000)}}.border-base-200\/90{border-color:var(--color-base-200);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-200)90%,#0000)}}.bg-base-300{background-color:var(--color-base-300)}.bg-base-300\/10{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)10%,#0000)}}.bg-base-300\/20{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)20%,#0000)}}.bg-base-300\/30{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)30%,#0000)}}.bg-base-300\/40{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)40%,#0000)}}.bg-base-300\/50{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)50%,#0000)}}.bg-base-300\/60{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)60%,#0000)}}.bg-base-300\/70{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)70%,#0000)}}.bg-base-300\/80{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)80%,#0000)}}.bg-base-300\/90{background-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-300)90%,#0000)}}.text-base-300{color:var(--color-base-300)}.text-base-300\/10{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)10%,#0000)}}.text-base-300\/20{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)20%,#0000)}}.text-base-300\/30{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)30%,#0000)}}.text-base-300\/40{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)40%,#0000)}}.text-base-300\/50{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)50%,#0000)}}.text-base-300\/60{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)60%,#0000)}}.text-base-300\/70{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)70%,#0000)}}.text-base-300\/80{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)80%,#0000)}}.text-base-300\/90{color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-300)90%,#0000)}}.border-base-300{border-color:var(--color-base-300)}.border-base-300\/10{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)10%,#0000)}}.border-base-300\/20{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)20%,#0000)}}.border-base-300\/30{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)30%,#0000)}}.border-base-300\/40{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)40%,#0000)}}.border-base-300\/50{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)50%,#0000)}}.border-base-300\/60{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)60%,#0000)}}.border-base-300\/70{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)70%,#0000)}}.border-base-300\/80{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)80%,#0000)}}.border-base-300\/90{border-color:var(--color-base-300);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-300)90%,#0000)}}.bg-base-content{background-color:var(--color-base-content)}.bg-base-content\/10{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)10%,#0000)}}.bg-base-content\/20{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)20%,#0000)}}.bg-base-content\/30{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)30%,#0000)}}.bg-base-content\/40{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)40%,#0000)}}.bg-base-content\/50{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)50%,#0000)}}.bg-base-content\/60{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)60%,#0000)}}.bg-base-content\/70{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)70%,#0000)}}.bg-base-content\/80{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)80%,#0000)}}.bg-base-content\/90{background-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-base-content)90%,#0000)}}.text-base-content{color:var(--color-base-content)}.text-base-content\/10{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)10%,#0000)}}.text-base-content\/20{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)20%,#0000)}}.text-base-content\/30{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)30%,#0000)}}.text-base-content\/40{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)40%,#0000)}}.text-base-content\/50{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)50%,#0000)}}.text-base-content\/60{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)60%,#0000)}}.text-base-content\/70{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)70%,#0000)}}.text-base-content\/80{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)80%,#0000)}}.text-base-content\/90{color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-base-content)90%,#0000)}}.border-base-content{border-color:var(--color-base-content)}.border-base-content\/10{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)10%,#0000)}}.border-base-content\/20{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)20%,#0000)}}.border-base-content\/30{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)30%,#0000)}}.border-base-content\/40{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)40%,#0000)}}.border-base-content\/50{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)50%,#0000)}}.border-base-content\/60{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)60%,#0000)}}.border-base-content\/70{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)70%,#0000)}}.border-base-content\/80{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)80%,#0000)}}.border-base-content\/90{border-color:var(--color-base-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-base-content)90%,#0000)}}.bg-primary{background-color:var(--color-primary)}.bg-primary\/10{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)10%,#0000)}}.bg-primary\/20{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)20%,#0000)}}.bg-primary\/30{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)30%,#0000)}}.bg-primary\/40{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)40%,#0000)}}.bg-primary\/50{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)50%,#0000)}}.bg-primary\/60{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)60%,#0000)}}.bg-primary\/70{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)70%,#0000)}}.bg-primary\/80{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)80%,#0000)}}.bg-primary\/90{background-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary)90%,#0000)}}.text-primary{color:var(--color-primary)}.text-primary\/10{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)10%,#0000)}}.text-primary\/20{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)20%,#0000)}}.text-primary\/30{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)30%,#0000)}}.text-primary\/40{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)40%,#0000)}}.text-primary\/50{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)50%,#0000)}}.text-primary\/60{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)60%,#0000)}}.text-primary\/70{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)70%,#0000)}}.text-primary\/80{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)80%,#0000)}}.text-primary\/90{color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary)90%,#0000)}}.border-primary{border-color:var(--color-primary)}.border-primary\/10{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)10%,#0000)}}.border-primary\/20{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)20%,#0000)}}.border-primary\/30{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)30%,#0000)}}.border-primary\/40{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)40%,#0000)}}.border-primary\/50{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)50%,#0000)}}.border-primary\/60{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)60%,#0000)}}.border-primary\/70{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)70%,#0000)}}.border-primary\/80{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)80%,#0000)}}.border-primary\/90{border-color:var(--color-primary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary)90%,#0000)}}.bg-primary-content{background-color:var(--color-primary-content)}.bg-primary-content\/10{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)10%,#0000)}}.bg-primary-content\/20{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)20%,#0000)}}.bg-primary-content\/30{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)30%,#0000)}}.bg-primary-content\/40{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)40%,#0000)}}.bg-primary-content\/50{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)50%,#0000)}}.bg-primary-content\/60{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)60%,#0000)}}.bg-primary-content\/70{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)70%,#0000)}}.bg-primary-content\/80{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)80%,#0000)}}.bg-primary-content\/90{background-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-primary-content)90%,#0000)}}.text-primary-content{color:var(--color-primary-content)}.text-primary-content\/10{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)10%,#0000)}}.text-primary-content\/20{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)20%,#0000)}}.text-primary-content\/30{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)30%,#0000)}}.text-primary-content\/40{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)40%,#0000)}}.text-primary-content\/50{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)50%,#0000)}}.text-primary-content\/60{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)60%,#0000)}}.text-primary-content\/70{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)70%,#0000)}}.text-primary-content\/80{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)80%,#0000)}}.text-primary-content\/90{color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-primary-content)90%,#0000)}}.border-primary-content{border-color:var(--color-primary-content)}.border-primary-content\/10{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)10%,#0000)}}.border-primary-content\/20{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)20%,#0000)}}.border-primary-content\/30{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)30%,#0000)}}.border-primary-content\/40{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)40%,#0000)}}.border-primary-content\/50{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)50%,#0000)}}.border-primary-content\/60{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)60%,#0000)}}.border-primary-content\/70{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)70%,#0000)}}.border-primary-content\/80{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)80%,#0000)}}.border-primary-content\/90{border-color:var(--color-primary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-primary-content)90%,#0000)}}.bg-secondary{background-color:var(--color-secondary)}.bg-secondary\/10{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)10%,#0000)}}.bg-secondary\/20{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)20%,#0000)}}.bg-secondary\/30{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)30%,#0000)}}.bg-secondary\/40{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)40%,#0000)}}.bg-secondary\/50{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)50%,#0000)}}.bg-secondary\/60{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)60%,#0000)}}.bg-secondary\/70{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)70%,#0000)}}.bg-secondary\/80{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)80%,#0000)}}.bg-secondary\/90{background-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary)90%,#0000)}}.text-secondary{color:var(--color-secondary)}.text-secondary\/10{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)10%,#0000)}}.text-secondary\/20{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)20%,#0000)}}.text-secondary\/30{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)30%,#0000)}}.text-secondary\/40{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)40%,#0000)}}.text-secondary\/50{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)50%,#0000)}}.text-secondary\/60{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)60%,#0000)}}.text-secondary\/70{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)70%,#0000)}}.text-secondary\/80{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)80%,#0000)}}.text-secondary\/90{color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary)90%,#0000)}}.border-secondary{border-color:var(--color-secondary)}.border-secondary\/10{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)10%,#0000)}}.border-secondary\/20{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)20%,#0000)}}.border-secondary\/30{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)30%,#0000)}}.border-secondary\/40{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)40%,#0000)}}.border-secondary\/50{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)50%,#0000)}}.border-secondary\/60{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)60%,#0000)}}.border-secondary\/70{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)70%,#0000)}}.border-secondary\/80{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)80%,#0000)}}.border-secondary\/90{border-color:var(--color-secondary);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary)90%,#0000)}}.bg-secondary-content{background-color:var(--color-secondary-content)}.bg-secondary-content\/10{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)10%,#0000)}}.bg-secondary-content\/20{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)20%,#0000)}}.bg-secondary-content\/30{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)30%,#0000)}}.bg-secondary-content\/40{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)40%,#0000)}}.bg-secondary-content\/50{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)50%,#0000)}}.bg-secondary-content\/60{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)60%,#0000)}}.bg-secondary-content\/70{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)70%,#0000)}}.bg-secondary-content\/80{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)80%,#0000)}}.bg-secondary-content\/90{background-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-secondary-content)90%,#0000)}}.text-secondary-content{color:var(--color-secondary-content)}.text-secondary-content\/10{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)10%,#0000)}}.text-secondary-content\/20{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)20%,#0000)}}.text-secondary-content\/30{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)30%,#0000)}}.text-secondary-content\/40{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)40%,#0000)}}.text-secondary-content\/50{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)50%,#0000)}}.text-secondary-content\/60{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)60%,#0000)}}.text-secondary-content\/70{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)70%,#0000)}}.text-secondary-content\/80{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)80%,#0000)}}.text-secondary-content\/90{color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-secondary-content)90%,#0000)}}.border-secondary-content{border-color:var(--color-secondary-content)}.border-secondary-content\/10{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)10%,#0000)}}.border-secondary-content\/20{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)20%,#0000)}}.border-secondary-content\/30{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)30%,#0000)}}.border-secondary-content\/40{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)40%,#0000)}}.border-secondary-content\/50{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)50%,#0000)}}.border-secondary-content\/60{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)60%,#0000)}}.border-secondary-content\/70{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)70%,#0000)}}.border-secondary-content\/80{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)80%,#0000)}}.border-secondary-content\/90{border-color:var(--color-secondary-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-secondary-content)90%,#0000)}}.bg-accent{background-color:var(--color-accent)}.bg-accent\/10{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)10%,#0000)}}.bg-accent\/20{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)20%,#0000)}}.bg-accent\/30{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)30%,#0000)}}.bg-accent\/40{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)40%,#0000)}}.bg-accent\/50{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)50%,#0000)}}.bg-accent\/60{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)60%,#0000)}}.bg-accent\/70{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)70%,#0000)}}.bg-accent\/80{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)80%,#0000)}}.bg-accent\/90{background-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent)90%,#0000)}}.text-accent{color:var(--color-accent)}.text-accent\/10{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)10%,#0000)}}.text-accent\/20{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)20%,#0000)}}.text-accent\/30{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)30%,#0000)}}.text-accent\/40{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)40%,#0000)}}.text-accent\/50{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)50%,#0000)}}.text-accent\/60{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)60%,#0000)}}.text-accent\/70{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)70%,#0000)}}.text-accent\/80{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)80%,#0000)}}.text-accent\/90{color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent)90%,#0000)}}.border-accent{border-color:var(--color-accent)}.border-accent\/10{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)10%,#0000)}}.border-accent\/20{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)20%,#0000)}}.border-accent\/30{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)30%,#0000)}}.border-accent\/40{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)40%,#0000)}}.border-accent\/50{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)50%,#0000)}}.border-accent\/60{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)60%,#0000)}}.border-accent\/70{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)70%,#0000)}}.border-accent\/80{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)80%,#0000)}}.border-accent\/90{border-color:var(--color-accent);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent)90%,#0000)}}.bg-accent-content{background-color:var(--color-accent-content)}.bg-accent-content\/10{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)10%,#0000)}}.bg-accent-content\/20{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)20%,#0000)}}.bg-accent-content\/30{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)30%,#0000)}}.bg-accent-content\/40{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)40%,#0000)}}.bg-accent-content\/50{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)50%,#0000)}}.bg-accent-content\/60{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)60%,#0000)}}.bg-accent-content\/70{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)70%,#0000)}}.bg-accent-content\/80{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)80%,#0000)}}.bg-accent-content\/90{background-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-accent-content)90%,#0000)}}.text-accent-content{color:var(--color-accent-content)}.text-accent-content\/10{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)10%,#0000)}}.text-accent-content\/20{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)20%,#0000)}}.text-accent-content\/30{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)30%,#0000)}}.text-accent-content\/40{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)40%,#0000)}}.text-accent-content\/50{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)50%,#0000)}}.text-accent-content\/60{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)60%,#0000)}}.text-accent-content\/70{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)70%,#0000)}}.text-accent-content\/80{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)80%,#0000)}}.text-accent-content\/90{color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-accent-content)90%,#0000)}}.border-accent-content{border-color:var(--color-accent-content)}.border-accent-content\/10{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)10%,#0000)}}.border-accent-content\/20{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)20%,#0000)}}.border-accent-content\/30{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)30%,#0000)}}.border-accent-content\/40{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)40%,#0000)}}.border-accent-content\/50{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)50%,#0000)}}.border-accent-content\/60{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)60%,#0000)}}.border-accent-content\/70{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)70%,#0000)}}.border-accent-content\/80{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)80%,#0000)}}.border-accent-content\/90{border-color:var(--color-accent-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-accent-content)90%,#0000)}}.bg-neutral{background-color:var(--color-neutral)}.bg-neutral\/10{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)10%,#0000)}}.bg-neutral\/20{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)20%,#0000)}}.bg-neutral\/30{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)30%,#0000)}}.bg-neutral\/40{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)40%,#0000)}}.bg-neutral\/50{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)50%,#0000)}}.bg-neutral\/60{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)60%,#0000)}}.bg-neutral\/70{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)70%,#0000)}}.bg-neutral\/80{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)80%,#0000)}}.bg-neutral\/90{background-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral)90%,#0000)}}.text-neutral{color:var(--color-neutral)}.text-neutral\/10{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)10%,#0000)}}.text-neutral\/20{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)20%,#0000)}}.text-neutral\/30{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)30%,#0000)}}.text-neutral\/40{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)40%,#0000)}}.text-neutral\/50{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)50%,#0000)}}.text-neutral\/60{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)60%,#0000)}}.text-neutral\/70{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)70%,#0000)}}.text-neutral\/80{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)80%,#0000)}}.text-neutral\/90{color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral)90%,#0000)}}.border-neutral{border-color:var(--color-neutral)}.border-neutral\/10{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)10%,#0000)}}.border-neutral\/20{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)20%,#0000)}}.border-neutral\/30{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)30%,#0000)}}.border-neutral\/40{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)40%,#0000)}}.border-neutral\/50{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)50%,#0000)}}.border-neutral\/60{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)60%,#0000)}}.border-neutral\/70{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)70%,#0000)}}.border-neutral\/80{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)80%,#0000)}}.border-neutral\/90{border-color:var(--color-neutral);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral)90%,#0000)}}.bg-neutral-content{background-color:var(--color-neutral-content)}.bg-neutral-content\/10{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)10%,#0000)}}.bg-neutral-content\/20{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)20%,#0000)}}.bg-neutral-content\/30{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)30%,#0000)}}.bg-neutral-content\/40{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)40%,#0000)}}.bg-neutral-content\/50{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)50%,#0000)}}.bg-neutral-content\/60{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)60%,#0000)}}.bg-neutral-content\/70{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)70%,#0000)}}.bg-neutral-content\/80{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)80%,#0000)}}.bg-neutral-content\/90{background-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-neutral-content)90%,#0000)}}.text-neutral-content{color:var(--color-neutral-content)}.text-neutral-content\/10{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)10%,#0000)}}.text-neutral-content\/20{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)20%,#0000)}}.text-neutral-content\/30{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)30%,#0000)}}.text-neutral-content\/40{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)40%,#0000)}}.text-neutral-content\/50{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)50%,#0000)}}.text-neutral-content\/60{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)60%,#0000)}}.text-neutral-content\/70{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)70%,#0000)}}.text-neutral-content\/80{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)80%,#0000)}}.text-neutral-content\/90{color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-neutral-content)90%,#0000)}}.border-neutral-content{border-color:var(--color-neutral-content)}.border-neutral-content\/10{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)10%,#0000)}}.border-neutral-content\/20{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)20%,#0000)}}.border-neutral-content\/30{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)30%,#0000)}}.border-neutral-content\/40{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)40%,#0000)}}.border-neutral-content\/50{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)50%,#0000)}}.border-neutral-content\/60{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)60%,#0000)}}.border-neutral-content\/70{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)70%,#0000)}}.border-neutral-content\/80{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)80%,#0000)}}.border-neutral-content\/90{border-color:var(--color-neutral-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-neutral-content)90%,#0000)}}.bg-info{background-color:var(--color-info)}.bg-info\/10{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)10%,#0000)}}.bg-info\/20{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)20%,#0000)}}.bg-info\/30{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)30%,#0000)}}.bg-info\/40{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)40%,#0000)}}.bg-info\/50{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)50%,#0000)}}.bg-info\/60{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)60%,#0000)}}.bg-info\/70{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)70%,#0000)}}.bg-info\/80{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)80%,#0000)}}.bg-info\/90{background-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info)90%,#0000)}}.text-info{color:var(--color-info)}.text-info\/10{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)10%,#0000)}}.text-info\/20{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)20%,#0000)}}.text-info\/30{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)30%,#0000)}}.text-info\/40{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)40%,#0000)}}.text-info\/50{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)50%,#0000)}}.text-info\/60{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)60%,#0000)}}.text-info\/70{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)70%,#0000)}}.text-info\/80{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)80%,#0000)}}.text-info\/90{color:var(--color-info);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info)90%,#0000)}}.border-info{border-color:var(--color-info)}.border-info\/10{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)10%,#0000)}}.border-info\/20{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)20%,#0000)}}.border-info\/30{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)30%,#0000)}}.border-info\/40{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)40%,#0000)}}.border-info\/50{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)50%,#0000)}}.border-info\/60{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)60%,#0000)}}.border-info\/70{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)70%,#0000)}}.border-info\/80{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)80%,#0000)}}.border-info\/90{border-color:var(--color-info);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info)90%,#0000)}}.bg-info-content{background-color:var(--color-info-content)}.bg-info-content\/10{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)10%,#0000)}}.bg-info-content\/20{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)20%,#0000)}}.bg-info-content\/30{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)30%,#0000)}}.bg-info-content\/40{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)40%,#0000)}}.bg-info-content\/50{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)50%,#0000)}}.bg-info-content\/60{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)60%,#0000)}}.bg-info-content\/70{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)70%,#0000)}}.bg-info-content\/80{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)80%,#0000)}}.bg-info-content\/90{background-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-info-content)90%,#0000)}}.text-info-content{color:var(--color-info-content)}.text-info-content\/10{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)10%,#0000)}}.text-info-content\/20{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)20%,#0000)}}.text-info-content\/30{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)30%,#0000)}}.text-info-content\/40{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)40%,#0000)}}.text-info-content\/50{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)50%,#0000)}}.text-info-content\/60{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)60%,#0000)}}.text-info-content\/70{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)70%,#0000)}}.text-info-content\/80{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)80%,#0000)}}.text-info-content\/90{color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-info-content)90%,#0000)}}.border-info-content{border-color:var(--color-info-content)}.border-info-content\/10{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)10%,#0000)}}.border-info-content\/20{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)20%,#0000)}}.border-info-content\/30{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)30%,#0000)}}.border-info-content\/40{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)40%,#0000)}}.border-info-content\/50{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)50%,#0000)}}.border-info-content\/60{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)60%,#0000)}}.border-info-content\/70{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)70%,#0000)}}.border-info-content\/80{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)80%,#0000)}}.border-info-content\/90{border-color:var(--color-info-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-info-content)90%,#0000)}}.bg-success{background-color:var(--color-success)}.bg-success\/10{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)10%,#0000)}}.bg-success\/20{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)20%,#0000)}}.bg-success\/30{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)30%,#0000)}}.bg-success\/40{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)40%,#0000)}}.bg-success\/50{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)50%,#0000)}}.bg-success\/60{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)60%,#0000)}}.bg-success\/70{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)70%,#0000)}}.bg-success\/80{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)80%,#0000)}}.bg-success\/90{background-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success)90%,#0000)}}.text-success{color:var(--color-success)}.text-success\/10{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)10%,#0000)}}.text-success\/20{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)20%,#0000)}}.text-success\/30{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)30%,#0000)}}.text-success\/40{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)40%,#0000)}}.text-success\/50{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)50%,#0000)}}.text-success\/60{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)60%,#0000)}}.text-success\/70{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)70%,#0000)}}.text-success\/80{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)80%,#0000)}}.text-success\/90{color:var(--color-success);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success)90%,#0000)}}.border-success{border-color:var(--color-success)}.border-success\/10{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)10%,#0000)}}.border-success\/20{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)20%,#0000)}}.border-success\/30{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)30%,#0000)}}.border-success\/40{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)40%,#0000)}}.border-success\/50{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)50%,#0000)}}.border-success\/60{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)60%,#0000)}}.border-success\/70{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)70%,#0000)}}.border-success\/80{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)80%,#0000)}}.border-success\/90{border-color:var(--color-success);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success)90%,#0000)}}.bg-success-content{background-color:var(--color-success-content)}.bg-success-content\/10{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)10%,#0000)}}.bg-success-content\/20{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)20%,#0000)}}.bg-success-content\/30{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)30%,#0000)}}.bg-success-content\/40{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)40%,#0000)}}.bg-success-content\/50{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)50%,#0000)}}.bg-success-content\/60{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)60%,#0000)}}.bg-success-content\/70{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)70%,#0000)}}.bg-success-content\/80{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)80%,#0000)}}.bg-success-content\/90{background-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-success-content)90%,#0000)}}.text-success-content{color:var(--color-success-content)}.text-success-content\/10{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)10%,#0000)}}.text-success-content\/20{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)20%,#0000)}}.text-success-content\/30{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)30%,#0000)}}.text-success-content\/40{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)40%,#0000)}}.text-success-content\/50{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)50%,#0000)}}.text-success-content\/60{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)60%,#0000)}}.text-success-content\/70{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)70%,#0000)}}.text-success-content\/80{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)80%,#0000)}}.text-success-content\/90{color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-success-content)90%,#0000)}}.border-success-content{border-color:var(--color-success-content)}.border-success-content\/10{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)10%,#0000)}}.border-success-content\/20{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)20%,#0000)}}.border-success-content\/30{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)30%,#0000)}}.border-success-content\/40{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)40%,#0000)}}.border-success-content\/50{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)50%,#0000)}}.border-success-content\/60{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)60%,#0000)}}.border-success-content\/70{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)70%,#0000)}}.border-success-content\/80{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)80%,#0000)}}.border-success-content\/90{border-color:var(--color-success-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-success-content)90%,#0000)}}.bg-warning{background-color:var(--color-warning)}.bg-warning\/10{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)10%,#0000)}}.bg-warning\/20{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)20%,#0000)}}.bg-warning\/30{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)30%,#0000)}}.bg-warning\/40{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)40%,#0000)}}.bg-warning\/50{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)50%,#0000)}}.bg-warning\/60{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)60%,#0000)}}.bg-warning\/70{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)70%,#0000)}}.bg-warning\/80{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)80%,#0000)}}.bg-warning\/90{background-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning)90%,#0000)}}.text-warning{color:var(--color-warning)}.text-warning\/10{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)10%,#0000)}}.text-warning\/20{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)20%,#0000)}}.text-warning\/30{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)30%,#0000)}}.text-warning\/40{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)40%,#0000)}}.text-warning\/50{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)50%,#0000)}}.text-warning\/60{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)60%,#0000)}}.text-warning\/70{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)70%,#0000)}}.text-warning\/80{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)80%,#0000)}}.text-warning\/90{color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning)90%,#0000)}}.border-warning{border-color:var(--color-warning)}.border-warning\/10{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)10%,#0000)}}.border-warning\/20{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)20%,#0000)}}.border-warning\/30{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)30%,#0000)}}.border-warning\/40{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)40%,#0000)}}.border-warning\/50{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)50%,#0000)}}.border-warning\/60{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)60%,#0000)}}.border-warning\/70{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)70%,#0000)}}.border-warning\/80{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)80%,#0000)}}.border-warning\/90{border-color:var(--color-warning);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning)90%,#0000)}}.bg-warning-content{background-color:var(--color-warning-content)}.bg-warning-content\/10{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)10%,#0000)}}.bg-warning-content\/20{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)20%,#0000)}}.bg-warning-content\/30{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)30%,#0000)}}.bg-warning-content\/40{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)40%,#0000)}}.bg-warning-content\/50{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)50%,#0000)}}.bg-warning-content\/60{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)60%,#0000)}}.bg-warning-content\/70{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)70%,#0000)}}.bg-warning-content\/80{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)80%,#0000)}}.bg-warning-content\/90{background-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-warning-content)90%,#0000)}}.text-warning-content{color:var(--color-warning-content)}.text-warning-content\/10{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)10%,#0000)}}.text-warning-content\/20{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)20%,#0000)}}.text-warning-content\/30{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)30%,#0000)}}.text-warning-content\/40{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)40%,#0000)}}.text-warning-content\/50{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)50%,#0000)}}.text-warning-content\/60{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)60%,#0000)}}.text-warning-content\/70{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)70%,#0000)}}.text-warning-content\/80{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)80%,#0000)}}.text-warning-content\/90{color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-warning-content)90%,#0000)}}.border-warning-content{border-color:var(--color-warning-content)}.border-warning-content\/10{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)10%,#0000)}}.border-warning-content\/20{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)20%,#0000)}}.border-warning-content\/30{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)30%,#0000)}}.border-warning-content\/40{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)40%,#0000)}}.border-warning-content\/50{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)50%,#0000)}}.border-warning-content\/60{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)60%,#0000)}}.border-warning-content\/70{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)70%,#0000)}}.border-warning-content\/80{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)80%,#0000)}}.border-warning-content\/90{border-color:var(--color-warning-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-warning-content)90%,#0000)}}.bg-error{background-color:var(--color-error)}.bg-error\/10{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)10%,#0000)}}.bg-error\/20{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)20%,#0000)}}.bg-error\/30{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)30%,#0000)}}.bg-error\/40{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)40%,#0000)}}.bg-error\/50{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)50%,#0000)}}.bg-error\/60{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)60%,#0000)}}.bg-error\/70{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)70%,#0000)}}.bg-error\/80{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)80%,#0000)}}.bg-error\/90{background-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error)90%,#0000)}}.text-error{color:var(--color-error)}.text-error\/10{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)10%,#0000)}}.text-error\/20{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)20%,#0000)}}.text-error\/30{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)30%,#0000)}}.text-error\/40{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)40%,#0000)}}.text-error\/50{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)50%,#0000)}}.text-error\/60{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)60%,#0000)}}.text-error\/70{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)70%,#0000)}}.text-error\/80{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)80%,#0000)}}.text-error\/90{color:var(--color-error);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error)90%,#0000)}}.border-error{border-color:var(--color-error)}.border-error\/10{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)10%,#0000)}}.border-error\/20{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)20%,#0000)}}.border-error\/30{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)30%,#0000)}}.border-error\/40{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)40%,#0000)}}.border-error\/50{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)50%,#0000)}}.border-error\/60{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)60%,#0000)}}.border-error\/70{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)70%,#0000)}}.border-error\/80{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)80%,#0000)}}.border-error\/90{border-color:var(--color-error);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error)90%,#0000)}}.bg-error-content{background-color:var(--color-error-content)}.bg-error-content\/10{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)10%,#0000)}}.bg-error-content\/20{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)20%,#0000)}}.bg-error-content\/30{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)30%,#0000)}}.bg-error-content\/40{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)40%,#0000)}}.bg-error-content\/50{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)50%,#0000)}}.bg-error-content\/60{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)60%,#0000)}}.bg-error-content\/70{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)70%,#0000)}}.bg-error-content\/80{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)80%,#0000)}}.bg-error-content\/90{background-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){background-color:color-mix(in oklab,var(--color-error-content)90%,#0000)}}.text-error-content{color:var(--color-error-content)}.text-error-content\/10{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)10%,#0000)}}.text-error-content\/20{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)20%,#0000)}}.text-error-content\/30{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)30%,#0000)}}.text-error-content\/40{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)40%,#0000)}}.text-error-content\/50{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)50%,#0000)}}.text-error-content\/60{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)60%,#0000)}}.text-error-content\/70{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)70%,#0000)}}.text-error-content\/80{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)80%,#0000)}}.text-error-content\/90{color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){color:color-mix(in oklab,var(--color-error-content)90%,#0000)}}.border-error-content{border-color:var(--color-error-content)}.border-error-content\/10{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)10%,#0000)}}.border-error-content\/20{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)20%,#0000)}}.border-error-content\/30{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)30%,#0000)}}.border-error-content\/40{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)40%,#0000)}}.border-error-content\/50{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)50%,#0000)}}.border-error-content\/60{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)60%,#0000)}}.border-error-content\/70{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)70%,#0000)}}.border-error-content\/80{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)80%,#0000)}}.border-error-content\/90{border-color:var(--color-error-content);@supports (color:color-mix(in lab, red, red)){border-color:color-mix(in oklab,var(--color-error-content)90%,#0000)}}@media (hover:hover){.hover\:bg-base-100:hover:hover{background-color:var(--color-base-100)}.hover\:text-base-100:hover:hover{color:var(--color-base-100)}.hover\:border-base-100:hover:hover{border-color:var(--color-base-100)}.hover\:bg-base-200:hover:hover{background-color:var(--color-base-200)}.hover\:text-base-200:hover:hover{color:var(--color-base-200)}.hover\:border-base-200:hover:hover{border-color:var(--color-base-200)}.hover\:bg-base-300:hover:hover{background-color:var(--color-base-300)}.hover\:text-base-300:hover:hover{color:var(--color-base-300)}.hover\:border-base-300:hover:hover{border-color:var(--color-base-300)}.hover\:bg-base-content:hover:hover{background-color:var(--color-base-content)}.hover\:text-base-content:hover:hover{color:var(--color-base-content)}.hover\:border-base-content:hover:hover{border-color:var(--color-base-content)}.hover\:bg-primary:hover:hover{background-color:var(--color-primary)}.hover\:text-primary:hover:hover{color:var(--color-primary)}.hover\:border-primary:hover:hover{border-color:var(--color-primary)}.hover\:bg-primary-content:hover:hover{background-color:var(--color-primary-content)}.hover\:text-primary-content:hover:hover{color:var(--color-primary-content)}.hover\:border-primary-content:hover:hover{border-color:var(--color-primary-content)}.hover\:bg-secondary:hover:hover{background-color:var(--color-secondary)}.hover\:text-secondary:hover:hover{color:var(--color-secondary)}.hover\:border-secondary:hover:hover{border-color:var(--color-secondary)}.hover\:bg-secondary-content:hover:hover{background-color:var(--color-secondary-content)}.hover\:text-secondary-content:hover:hover{color:var(--color-secondary-content)}.hover\:border-secondary-content:hover:hover{border-color:var(--color-secondary-content)}.hover\:bg-accent:hover:hover{background-color:var(--color-accent)}.hover\:text-accent:hover:hover{color:var(--color-accent)}.hover\:border-accent:hover:hover{border-color:var(--color-accent)}.hover\:bg-accent-content:hover:hover{background-color:var(--color-accent-content)}.hover\:text-accent-content:hover:hover{color:var(--color-accent-content)}.hover\:border-accent-content:hover:hover{border-color:var(--color-accent-content)}.hover\:bg-neutral:hover:hover{background-color:var(--color-neutral)}.hover\:text-neutral:hover:hover{color:var(--color-neutral)}.hover\:border-neutral:hover:hover{border-color:var(--color-neutral)}.hover\:bg-neutral-content:hover:hover{background-color:var(--color-neutral-content)}.hover\:text-neutral-content:hover:hover{color:var(--color-neutral-content)}.hover\:border-neutral-content:hover:hover{border-color:var(--color-neutral-content)}.hover\:bg-info:hover:hover{background-color:var(--color-info)}.hover\:text-info:hover:hover{color:var(--color-info)}.hover\:border-info:hover:hover{border-color:var(--color-info)}.hover\:bg-info-content:hover:hover{background-color:var(--color-info-content)}.hover\:text-info-content:hover:hover{color:var(--color-info-content)}.hover\:border-info-content:hover:hover{border-color:var(--color-info-content)}.hover\:bg-success:hover:hover{background-color:var(--color-success)}.hover\:text-success:hover:hover{color:var(--color-success)}.hover\:border-success:hover:hover{border-color:var(--color-success)}.hover\:bg-success-content:hover:hover{background-color:var(--color-success-content)}.hover\:text-success-content:hover:hover{color:var(--color-success-content)}.hover\:border-success-content:hover:hover{border-color:var(--color-success-content)}.hover\:bg-warning:hover:hover{background-color:var(--color-warning)}.hover\:text-warning:hover:hover{color:var(--color-warning)}.hover\:border-warning:hover:hover{border-color:var(--color-warning)}.hover\:bg-warning-content:hover:hover{background-color:var(--color-warning-content)}.hover\:text-warning-content:hover:hover{color:var(--color-warning-content)}.hover\:border-warning-content:hover:hover{border-color:var(--color-warning-content)}.hover\:bg-error:hover:hover{background-color:var(--color-error)}.hover\:text-error:hover:hover{color:var(--color-error)}.hover\:border-error:hover:hover{border-color:var(--color-error)}.hover\:bg-error-content:hover:hover{background-color:var(--color-error-content)}.hover\:text-error-content:hover:hover{color:var(--color-error-content)}.hover\:border-error-content:hover:hover{border-color:var(--color-error-content)}}} \ No newline at end of file diff --git a/Software/web-server/static/vendor/tailwindcss-browser-4.3.3.js b/Software/web-server/static/vendor/tailwindcss-browser-4.3.3.js new file mode 100644 index 00000000..aa8caf1b --- /dev/null +++ b/Software/web-server/static/vendor/tailwindcss-browser-4.3.3.js @@ -0,0 +1,952 @@ +"use strict";(()=>{var pr="4.3.3";function ii(e){return{kind:"combinator",value:e}}function Rt(e){return{kind:"complex",nodes:e}}function dr(e){return{kind:"compound",nodes:e}}function ni(e,r){return{kind:"function",value:e,nodes:r}}function li(e){return{kind:"list",nodes:e}}function he(e){return{kind:"selector",value:e}}function mr(e){return{kind:"value",value:e}}function De(e){return e.kind==="selector"&&e.value.charCodeAt(0)===zt}function et(e){if(e.kind!=="selector")return!1;switch(e.value.charCodeAt(0)){case zt:case Cr:case xr:case Ar:case Lt:case Ut:return!1;default:return!0}}function pe(e,r=!1){let o="";for(let t of e)switch(t.kind){case"selector":case"value":{o+=t.value;break}case"combinator":{r||t.value===" "?o+=t.value:o+=` ${t.value} `;break}case"function":{o+=`${t.value}(${pe(t.nodes,r)})`;break}case"complex":case"compound":{o+=pe(t.nodes,r);break}case"list":{o+=t.nodes.map(l=>pe([l],r)).join(r?",":", ");break}}return o}var hr=92,ai=93,gr=41,Lt=58,kr=44,si=34,xr=46,vr=62,Pt=10,Ar=35,Ut=91,br=40,wr=43,ui=39,Ot=32,_t=9,yr=126,Cr=38,zt=42;function ge(e){e=e.replaceAll(`\r +`,` +`);let r=[],o=r,t=!1,l=[],s=null,a="",m;function h(u=o){return u.length===1?u[0]:t?Rt(u):dr(u)}function f(u){let p=o[o.length-1];p?.kind==="compound"?p.nodes.push(u):p&&p.kind!=="list"&&p.kind!=="combinator"?o[o.length-1]=dr([p,u]):o.push(u)}for(let u=0;u0&&(f(he(a)),a="");u+10&&(f(he(a)),a="");let c=u,v=u+1;for(;v=e.length||e.charCodeAt(v)===kr))break;o.push(ii(g===""?" ":g)),t=!0;break}case br:{let c=ni(a,[]);if(a="",c.value!==":not"&&c.value!==":where"&&c.value!==":has"&&c.value!==":is"){let v=u+1,g=0;for(let T=u+1;T0&&(f(he(a)),a=""),s?s.nodes.push(h()):t&&o.splice(0,o.length,Rt(o.splice(0)));let c=l.pop();o=c?.target??r,s=c?.currentList??null,t=c?.containsCombinator??!1;break}case xr:case Lt:case Ar:{if(p===Lt&&a===":"){a+=e[u];break}a.length>0&&f(he(a)),a=e[u];break}case Ut:{a.length>0&&(f(he(a)),a="");let c=u,v=0;for(let g=u+1;g0&&(f(he(a)),a=""),f(he(e[u]));break}case hr:{a+=e[u]+e[u+1],u+=1;break}default:a+=e[u]}}return a.length>0&&f(he(a)),s?s.nodes.push(h()):t&&o.splice(0,o.length,Rt(o.splice(0))),r}function tt(e){let r=[0];for(let l=0;l0;){let h=(a|0)>>1,f=s+h;r[f]<=l?(s=f+1,a=a-h-1):a=h}s-=1;let m=l-r[s];return{line:s+1,column:m}}function t({line:l,column:s}){l-=1,l=Math.min(Math.max(l,0),r.length-1);let a=r[l],m=r[l+1]??a;return Math.min(Math.max(a+s,0),m)}return{find:o,findOffset:t}}var Ie=92,rt=47,ot=42,Nr=34,Tr=39,ci=58,it=59,se=10,nt=13,je=32,Fe=9,$r=123,Kt=125,jt=40,Er=41,fi=91,pi=93,Vr=45,Dt=64,di=33,ue=class e extends Error{loc;constructor(r,o){if(o){let t=o[0],l=tt(t.code).find(o[1]);r=`${t.file}:${l.line}:${l.column+1}: ${r}`}super(r),this.name="CssSyntaxError",this.loc=o,Error.captureStackTrace&&Error.captureStackTrace(this,e)}};function Pe(e,r){let o=r?.from?{file:r.from,code:e}:null;e[0]==="\uFEFF"&&(e=" "+e.slice(1));let t=[],l=[],s=[],a=null,m=null,h="",f="",u=0,p;for(let c=0;c0&&e[A]===g[g.length-1]&&(g=g.slice(0,-1));let T=It(h,x);if(!T)throw new ue("Invalid custom property, expected a value",o?[o,b,c]:null);o&&(T.src=[o,b,c],T.dst=[o,b,c]),a?a.nodes.push(T):t.push(T),h=""}else if(v===it&&h.charCodeAt(0)===Dt)m=Me(h),o&&(m.src=[o,u,c],m.dst=[o,u,c]),a?a.nodes.push(m):t.push(m),h="",m=null;else if(v===it&&f[f.length-1]!==")"){let g=It(h);if(!g){if(h.length===0)continue;throw new ue(`Invalid declaration: \`${h.trim()}\``,o?[o,u,c]:null)}o&&(g.src=[o,u,c],g.dst=[o,u,c]),a?a.nodes.push(g):t.push(g),h=""}else if(v===$r&&f[f.length-1]!==")")f+="}",m=Y(h.trim()),o&&(m.src=[o,u,c],m.dst=[o,u,c]),a&&a.nodes.push(m),s.push(a),a=m,h="",m=null;else if(v===Kt&&f[f.length-1]!==")"){if(f==="")throw new ue("Missing opening {",o?[o,c,c]:null);if(f=f.slice(0,-1),h.length>0)if(h.charCodeAt(0)===Dt)m=Me(h),o&&(m.src=[o,u,c],m.dst=[o,u,c]),a?a.nodes.push(m):t.push(m),h="",m=null;else{let b=h.indexOf(":");if(a){let x=It(h,b);if(!x)throw new ue(`Invalid declaration: \`${h.trim()}\``,o?[o,u,c]:null);o&&(x.src=[o,u,c],x.dst=[o,u,c]),a.nodes.push(x)}}let g=s.pop()??null;g===null&&a&&t.push(a),a=g,h="",m=null}else if(v===jt)f+=")",h+="(";else if(v===Er){if(f[f.length-1]!==")")throw new ue("Missing opening (",o?[o,c,c]:null);f=f.slice(0,-1),h+=")"}else{if(h.length===0&&(v===je||v===se||v===Fe))continue;h===""&&(u=c),h+=String.fromCharCode(v)}}}if(h.charCodeAt(0)===Dt){let c=Me(h);o&&(c.src=[o,u,e.length],c.dst=[o,u,e.length]),t.push(c)}if(f.length>0&&a){if(a.kind==="rule")throw new ue(`Missing closing } at ${a.selector}`,a.src?[a.src[0],a.src[1],a.src[1]]:null);if(a.kind==="at-rule")throw new ue(`Missing closing } at ${a.name} ${a.params}`,a.src?[a.src[0],a.src[1],a.src[1]]:null)}return l.length>0?l.concat(t):t}function Me(e,r=[]){let o=e,t="";for(let l=5;l=1&&l<=31||l===127||t===0&&l>=48&&l<=57||t===1&&l>=48&&l<=57&&a===45){s+="\\"+l.toString(16)+" ";continue}if(l>=128||l===45||l===95||l>=48&&l<=57||l>=65&&l<=90||l>=97&&l<=122){s+=r.charAt(t);continue}s+="\\"+r.charAt(t)}return s}function ke(e){return e.replace(/\\([\dA-Fa-f]{1,6}[\t\n\f\r ]?|[\S\s])/g,r=>{if(r.length<=2)return r[1];let o=Number.parseInt(r.slice(1).trim(),16);return o===0||o>1114111||o>=55296&&o<=57343?"\uFFFD":String.fromCodePoint(o)})}var Or=new Map([["--font",["--font-weight","--font-size"]],["--inset",["--inset-shadow","--inset-ring"]],["--text",["--text-color","--text-decoration-color","--text-decoration-thickness","--text-indent","--text-shadow","--text-underline-offset"]],["--grid-column",["--grid-column-start","--grid-column-end"]],["--grid-row",["--grid-row-start","--grid-row-end"]]]);function Pr(e,r){return(Or.get(r)??[]).some(o=>e===o||e.startsWith(`${o}-`))}var at=class{constructor(r=new Map,o=new Set([])){this.values=r;this.keyframes=o}values;keyframes;prefix=null;get size(){return this.values.size}add(r,o,t=0,l){if(r.endsWith("-*")){if(o!=="initial")throw new Error(`Invalid theme value \`${o}\` for namespace \`${r}\``);r==="--*"?this.values.clear():this.clearNamespace(r.slice(0,-2),0)}if(t&4){let s=this.values.get(r);if(s&&!(s.options&4))return}o==="initial"?this.values.delete(r):this.values.set(r,{value:o,options:t,src:l})}keysInNamespaces(r){let o=[];for(let t of r){let l=`${t}-`;for(let s of this.values.keys())s.startsWith(l)&&s.indexOf("--",2)===-1&&(Pr(s,t)||o.push(s.slice(l.length)))}return o}get(r){for(let o of r){let t=this.values.get(o);if(t)return t.value}return null}hasDefault(r){return(this.getOptions(r)&4)===4}getOptions(r){return r=ke(this.#r(r)),this.values.get(r)?.options??0}entries(){return this.prefix?Array.from(this.values,r=>(r[0]=this.prefixKey(r[0]),r)):this.values.entries()}prefixKey(r){return this.prefix?`--${this.prefix}-${r.slice(2)}`:r}#r(r){return this.prefix?`--${r.slice(3+this.prefix.length)}`:r}clearNamespace(r,o){let t=Or.get(r)??[];e:for(let l of this.values.keys())if(l.startsWith(r)){if(o!==0&&(this.getOptions(l)&o)!==o)continue;for(let s of t)if(l.startsWith(s))continue e;this.values.delete(l)}}#e(r,o){for(let t of o){let l=r!==null?`${t}-${r}`:t;if(!this.values.has(l))if(r!==null&&r.includes(".")){if(l=`${t}-${r.replaceAll(".","_")}`,!this.values.has(l))continue}else continue;if(!Pr(l,t))return l}return null}#t(r){let o=this.values.get(r);if(!o)return null;let t=null;return o.options&2&&(t=o.value),`var(${xe(this.prefixKey(r))}${t?`, ${t}`:""})`}markUsedVariable(r){let o=ke(this.#r(r)),t=this.values.get(o);if(!t)return!1;let l=t.options&16;return t.options|=16,!l}resolve(r,o,t=0){let l=this.#e(r,o);if(!l)return null;let s=this.values.get(l);return(t|s.options)&1?s.value:this.#t(l)}resolveValue(r,o){let t=this.#e(r,o);return t?this.values.get(t).value:null}resolveWith(r,o,t=[]){let l=this.#e(r,o);if(!l)return null;let s={};for(let m of t){let h=`${l}${m}`,f=this.values.get(h);f&&(f.options&1?s[m]=f.value:s[m]=this.#t(h))}let a=this.values.get(l);return a.options&1?[a.value,s]:[this.#t(l),s]}namespace(r){let o=new Map,t=`${r}-`;for(let[l,s]of this.values)l===r?o.set(null,s.value):l.startsWith(`${t}-`)?o.set(l.slice(r.length),s.value):l.startsWith(t)&&o.set(l.slice(t.length),s.value);return o}addKeyframes(r){this.keyframes.add(r)}getKeyframes(){return Array.from(this.keyframes)}};var H=class extends Map{constructor(o){super();this.factory=o}factory;get(o){let t=super.get(o);return t===void 0&&(t=this.factory(o,this),this.set(o,t)),t}};var st=new Uint8Array(256);function U(e,r){let o=0,t=[],l=0,s=e.length,a=r.charCodeAt(0);for(let m=0;m0&&h===st[o-1]&&o--;break}}return t.push(e.slice(l)),t}function We(e){return{kind:"word",value:e}}function mi(e,r){return{kind:"function",value:e,nodes:r}}function hi(e){return{kind:"separator",value:e}}function Z(e){let r="";for(let o of e)switch(o.kind){case"word":case"separator":{r+=o.value;break}case"function":r+=o.value+"("+Z(o.nodes)+")"}return r}var _r=92,gi=41,Lr=58,Ur=44,ki=34,zr=61,Kr=62,Dr=60,Ir=10,vi=40,bi=39,wi=47,jr=32,Fr=9;function q(e){e=e.replaceAll(`\r +`,` +`);let r=[],o=[],t=null,l="",s;for(let a=0;a0){let f=We(l);t?t.nodes.push(f):r.push(f),l=""}let h=We(e[a]);t?t.nodes.push(h):r.push(h);break}case Lr:case Ur:case zr:case Kr:case Dr:case Ir:case jr:case Fr:{if(l.length>0){let p=We(l);t?t.nodes.push(p):r.push(p),l=""}let h=a,f=a+1;for(;f0){let f=We(l);h?.nodes.push(f),l=""}o.length>0?t=o[o.length-1]:t=null;break}default:l+=String.fromCharCode(m)}}return l.length>0&&r.push(We(l)),r}var Mt=(a=>(a[a.Continue=0]="Continue",a[a.Skip=1]="Skip",a[a.Stop=2]="Stop",a[a.Replace=3]="Replace",a[a.ReplaceSkip=4]="ReplaceSkip",a[a.ReplaceStop=5]="ReplaceStop",a))(Mt||{}),V={Continue:{kind:0},Skip:{kind:1},Stop:{kind:2},Replace:e=>({kind:3,nodes:Array.isArray(e)?e:[e]}),ReplaceSkip:e=>({kind:4,nodes:Array.isArray(e)?e:[e]}),ReplaceStop:e=>({kind:5,nodes:Array.isArray(e)?e:[e]})};function O(e,r){typeof r=="function"?Mr(e,r):Mr(e,r.enter,r.exit)}function Mr(e,r=()=>V.Continue,o=()=>V.Continue){let t={value:[e,0,null],prev:null},l={parent:null,depth:0,index:0,siblings:e,path(){let s=[],a=t;for(;a;){let m=a.value[2];m&&s.push(m),a=a.prev}return s.reverse(),s}};for(;t!==null;){let s=t.value,a=s[0],m=s[1],h=s[2];if(m>=a.length){t=t.prev,l.depth-=1;continue}if(l.parent=h,l.siblings=a,m>=0){l.index=m;let c=a[m],v=r(c,l)??V.Continue;switch(v.kind){case 0:{c.nodes&&c.nodes.length>0&&(l.depth+=1,t={value:[c.nodes,0,c],prev:t}),s[1]=~m;continue}case 2:return;case 1:{s[1]=~m;continue}case 3:{a.splice(m,1,...v.nodes);continue}case 5:{a.splice(m,1,...v.nodes);return}case 4:{a.splice(m,1,...v.nodes),s[1]+=v.nodes.length;continue}default:throw new Error(`Invalid \`WalkAction.${Mt[v.kind]??`Unknown(${v.kind})`}\` in enter.`)}}let f=~m;l.index=f;let u=a[f],p=o(u,l)??V.Continue;switch(p.kind){case 0:s[1]=f+1;continue;case 2:return;case 3:{a.splice(f,1,...p.nodes),s[1]=f+p.nodes.length;continue}case 5:{a.splice(f,1,...p.nodes);return}case 4:{a.splice(f,1,...p.nodes),s[1]=f+p.nodes.length;continue}default:throw new Error(`Invalid \`WalkAction.${Mt[p.kind]??`Unknown(${p.kind})`}\` in exit.`)}}}var yi=new H(e=>{let r=[];return O(q(e),o=>{if(!(o.kind!=="function"||o.value!=="var"))return O(o.nodes,t=>{t.kind!=="word"||t.value[0]!=="-"||t.value[1]!=="-"||r.push(t.value)}),V.Skip}),r});function ut(e){return yi.get(e)}var xi=64,Ai=124;function G(e,r=[]){return{kind:"rule",selector:e,nodes:r}}function M(e,r="",o=[]){return{kind:"at-rule",name:e,params:r,nodes:o}}function Y(e,r=[]){return e.charCodeAt(0)===xi?Me(e,r):G(e,r)}function n(e,r,o=!1){return{kind:"declaration",property:e,value:r,important:o}}function lt(e){return{kind:"comment",value:e}}function de(e,r){return{kind:"context",context:e,nodes:r}}function F(e){return{kind:"at-root",nodes:e}}function re(e){switch(e.kind){case"rule":return{kind:e.kind,selector:e.selector,nodes:e.nodes.map(re),src:e.src,dst:e.dst};case"at-rule":return{kind:e.kind,name:e.name,params:e.params,nodes:e.nodes.map(re),src:e.src,dst:e.dst};case"at-root":return{kind:e.kind,nodes:e.nodes.map(re),src:e.src,dst:e.dst};case"context":return{kind:e.kind,context:{...e.context},nodes:e.nodes.map(re),src:e.src,dst:e.dst};case"declaration":return{kind:e.kind,property:e.property,value:e.value,important:e.important,src:e.src,dst:e.dst};case"comment":return{kind:e.kind,value:e.value,src:e.src,dst:e.dst};default:throw new Error(`Unknown node kind: ${e.kind}`)}}function Be(e){return{depth:e.depth,index:e.index,siblings:e.siblings,get context(){let r={};for(let o of e.path())o.kind==="context"&&Object.assign(r,o.context);return Object.defineProperty(this,"context",{value:r}),r},get parent(){let r=this.path().pop()??null;return Object.defineProperty(this,"parent",{value:r}),r},path(){return e.path().filter(r=>r.kind!=="context")}}}function Te(e,r,o=3){let t=[],l=new Set,s=new H(()=>new Set),a=new H(()=>new Set),m=new Set,h=new Set,f=[],u=[],p=new H(()=>new Set);function c(g,b,x={},T=0){if(g.kind==="declaration"){if(g.property==="--tw-sort"||g.value===void 0||g.value===null)return;if(x.theme&&g.property[0]==="-"&&g.property[1]==="-"){if(g.value==="initial"){g.value=void 0;return}x.keyframes||s.get(b).add(g)}if(g.value.includes("var("))if(x.theme&&g.property[0]==="-"&&g.property[1]==="-")for(let A of ut(g.value))p.get(A).add(g.property);else r.trackUsedVariables(g.value);if(g.property==="animation")for(let A of Wr(g.value))h.add(A);o&2&&!x.supportsColorMix&&!x.keyframes&&g.value.includes("color-mix(")&&a.get(b).add(g),b.push(g)}else if(g.kind==="rule"){let A=[];for(let w of g.nodes)c(w,A,x,T+1);A.length>0&&b.push({...g,nodes:A})}else if(g.kind==="at-rule"&&g.name==="@property"&&T===0){if(l.has(g.params))return;if(o&1){let w=g.params,K=null,L=!1;for(let I of g.nodes)I.kind==="declaration"&&(I.property==="initial-value"?K=I.value:I.property==="inherits"&&(L=I.value==="true"));let z=n(w,K??"initial");z.src=g.src,L?f.push(z):u.push(z)}l.add(g.params);let A={...g,nodes:[]};for(let w of g.nodes)c(w,A.nodes,x,T+1);b.push(A)}else if(g.kind==="at-rule"){g.name==="@keyframes"?x={...x,keyframes:!0}:g.name==="@supports"&&g.params.includes("color-mix(")&&(x={...x,supportsColorMix:!0});let A={...g,nodes:[]};for(let w of g.nodes)c(w,A.nodes,x,T+1);g.name==="@keyframes"&&x.theme&&m.add(A),(A.nodes.length>0||A.name==="@layer"||A.name==="@charset"||A.name==="@custom-media"||A.name==="@namespace"||A.name==="@import"||A.name==="@apply")&&b.push(A)}else if(g.kind==="at-root")for(let A of g.nodes){let w=[];c(A,w,x,0);for(let K of w)t.push(K)}else if(g.kind==="context"){if(g.context.reference)return;for(let A of g.nodes)c(A,b,{...x,...g.context},T)}else g.kind==="comment"&&b.push(g)}let v=[];for(let g of e)c(g,v,{},0);e:for(let[g,b]of s)for(let x of b){if(Br(x.property,r.theme,p)){if(x.property.startsWith(r.theme.prefixKey("--animate-")))for(let w of Wr(x.value))h.add(w);continue}let A=g.indexOf(x);if(g.splice(A,1),g.length===0){let w=Ti(v,K=>K.kind==="rule"&&K.nodes===g);if(!w||w.length===0)continue e;w.unshift({kind:"at-root",nodes:v});do{let K=w.pop();if(!K)break;let L=w[w.length-1];if(!L||L.kind!=="at-root"&&L.kind!=="at-rule")break;let z=L.nodes.indexOf(K);if(z===-1)break;L.nodes.splice(z,1)}while(!0);continue e}}for(let g of m)if(!h.has(g.params)){let b=t.indexOf(g);t.splice(b,1)}if(v=v.concat(t),o&2)for(let[g,b]of a)for(let x of b){let T=g.indexOf(x);if(T===-1||x.value==null)continue;let A=q(x.value),w=!1;if(O(A,z=>{if(z.kind!=="function"||z.value!=="color-mix")return;let I=!1,P=!1;if(O(z.nodes,D=>{if(D.kind=="word"&&D.value.toLowerCase()==="currentcolor"){P=!0,w=!0;return}let B=D,W=null,X=new Set;do{if(B.kind!=="function"||B.value!=="var")return;let te=B.nodes[0];if(!te||te.kind!=="word")return;let i=te.value;if(X.has(i)){I=!0;return}if(X.add(i),w=!0,W=r.theme.resolveValue(null,[te.value]),!W){I=!0;return}if(W.toLowerCase()==="currentcolor"){P=!0;return}W.startsWith("var(")?B=q(W)[0]:B=null}while(B);return V.Replace({kind:"word",value:W})}),I||P){let D=z.nodes.findIndex(W=>W.kind==="separator"&&W.value.trim().includes(","));if(D===-1)return;let B=z.nodes.length>D?z.nodes[D+1]:null;return B?V.Replace(B):void 0}else if(w){let D=z.nodes[2];D.kind==="word"&&(D.value==="oklab"||D.value==="oklch"||D.value==="lab"||D.value==="lch")&&(D.value="srgb")}}),!w)continue;let K={...x,value:Z(A)},L=Y("@supports (color: color-mix(in lab, red, red))",[x]);L.src=x.src,g.splice(T,1,K,L)}if(o&1){let g=[];if(f.length>0){let b=Y(":root, :host",f);b.src=f[0].src,g.push(b)}if(u.length>0){let b=Y("*, ::before, ::after, ::backdrop",u);b.src=u[0].src,g.push(b)}if(g.length>0){let b=v.findIndex(A=>!(A.kind==="comment"||A.kind==="at-rule"&&(A.name==="@charset"||A.name==="@import"))),x=M("@layer","properties",[]);x.src=g[0].src,v.splice(b<0?v.length:b,0,x);let T=Y("@layer properties",[M("@supports","((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b))))",g)]);T.src=g[0].src,T.nodes[0].src=g[0].src,v.push(T)}}return Ci(v)}function Ci(e){let r=new H(ge),o=[],t=[],l=null,s=new Set,a=new Set,m=[],h=new Set;O(e,{enter(u){switch(u.kind){case"rule":{if(l=null,o.length===0)if(u.selector.includes("&")){let p=ge(u.selector),c=!1;O(p,v=>{v.kind==="selector"&&v.value==="&"&&(c=!0,v.value=":scope")}),c?o.push([pe(p),u.src,u.dst]):o.push([u.selector,u.src,u.dst])}else o.push([u.selector,u.src,u.dst]);else{if(u.selector==="&"){h.add(u);return}let p=o[o.length-1][0],c=U(u.selector,",").map(v=>{if(!v.includes("&")){let g=r.get(p);return`${g.length===1&&g[0].kind==="list"?`:is(${p})`:p} ${v}`}{let g=ge(v),b=!1;if(O(g,{enter(T,A){if(T.kind!=="selector"||T.value!=="&"||(b=!0,T.value=`:is(${p})`,A.parent===null))return;let w=r.get(p);if(!(w.length===1&&w[0].kind==="list")){if(A.parent.kind==="complex")if(A.index===0){T.value=p;return}else if(A.index===A.siblings.length-1){if(w[0].kind==="complex")return;T.value=p;return}else{if(w[0].kind==="complex")return;T.value=p;return}else if(A.parent.kind==="compound"){if(w[0].kind==="complex"){let K=A.path(),L=K[K.length-2];if(L&&L.kind==="complex"&&L.nodes[0]!==A.parent)return}if(A.siblings.slice(A.index+1).some(K=>De(K)||et(K)))return;if(A.index===0){T.value=p;return}else if(A.index===A.siblings.length-1){if(w[0].kind==="complex"||De(w[0])||et(w[0]))return;T.value=p;return}else{if(w[0].kind==="complex"||De(w[0])||et(w[0]))return;T.value=p;return}}else if(A.parent.kind==="function"){T.value=p;return}}},exit(T,A){if(A.index===0&&A.siblings.length>1&&A.parent?.kind==="compound"&&De(T)){let w=A.siblings[1];return w.kind==="selector"&&w.value.charCodeAt(0)===Ai?void 0:V.ReplaceSkip([])}}}),b)return pe(g);let x=r.get(p);return`${x.length===1&&x[0].kind==="list"?`:is(${p})`:p} ${v}`}}).join(", ");o.push([c,u.src,u.dst])}if(u.nodes.some(p=>p.kind==="declaration")){for(let p of u.nodes)f(p);return V.Skip}break}case"at-rule":{if(l=null,u.nodes.length===0&&!Ni.has(u.name))return f(u),h.add(u),V.Skip;if(Si.has(u.name))t.push([u.name,u.params,u.src,u.dst]);else return f(u),h.add(u),V.Skip;break}case"declaration":case"comment":{f(u);break}case"context":case"at-root":break;default:break}},exit(u){if(!h.delete(u))switch(u.kind){case"rule":{l=null,o.pop();break}case"at-rule":{l=null,t.pop();break}case"declaration":case"comment":case"context":case"at-root":break;default:break}}});for(let u of a){let p=new Set;for(let c=u.length-1;c>=0;--c){let v=u[c];if(v.kind!=="declaration")continue;let g=`${v.property}\0${v.value}\0${v.important}`;p.has(g)?u.splice(c,1):p.add(g)}}return m;function f(u){if(l){u.kind==="declaration"&&(s.has(u.property)?a.add(l):s.add(u.property)),l.push(u);return}{if(o.length===0&&t.length===0){let g=m,b=g[g.length-1];if(b&&b.kind==="at-rule"&&u.kind==="at-rule"&&b.nodes.length===0&&u.nodes.length===0&&b.name===u.name&&b.params===u.params)return;m.push(u);return}l=[u],s.clear(),u.kind==="declaration"&&s.add(u.property);let p=null,c=m,v=0;{let g=c[c.length-1];if(g&&g.kind==="at-rule")for(let b=0;b0){let[g,b,x]=o[o.length-1];if(t.length-v<=0){let T=c[c.length-1];if(T&&T.kind==="rule"&&T.selector===g){T.nodes.push(...l),l=T.nodes,a.add(l);return}}p=Y(g,l),(b||x)&&Object.assign(p,{src:b,dst:x})}for(let g=t.length-1;g>=v;--g){let[b,x,T,A]=t[g];p=M(b,x,p?[p]:l),(T||A)&&Object.assign(p,{src:T,dst:A})}p?c.push(p):c.push(...l)}}}var Si=new Set(["@container","@layer","@media","@page","@starting-style","@supports","@view-transition"]),Ni=new Set(["@container","@media","@page","@starting-style","@supports","@view-transition"]);function ae(e,r){let o=0,t={file:null,code:""};function l(a,m=0){let h="",f=" ".repeat(m);if(a.kind==="declaration"){if(h+=`${f}${a.property}: ${a.value}${a.important?" !important":""}; +`,r){o+=f.length;let u=o;o+=a.property.length,o+=2,o+=a.value?.length??0,a.important&&(o+=11);let p=o;o+=2,a.dst=[t,u,p]}}else if(a.kind==="rule"){if(h+=`${f}${a.selector} { +`,r){o+=f.length;let u=o;o+=a.selector.length,o+=1;let p=o;a.dst=[t,u,p],o+=2}for(let u of a.nodes)h+=l(u,m+1);h+=`${f}} +`,r&&(o+=f.length,o+=2)}else if(a.kind==="at-rule"){if(a.nodes.length===0){let u=`${f}${a.name} ${a.params}; +`;if(r){o+=f.length;let p=o;o+=a.name.length,o+=1,o+=a.params.length;let c=o;o+=2,a.dst=[t,p,c]}return u}if(h+=`${f}${a.name}${a.params?` ${a.params} `:" "}{ +`,r){o+=f.length;let u=o;o+=a.name.length,a.params&&(o+=1,o+=a.params.length),o+=1;let p=o;a.dst=[t,u,p],o+=2}for(let u of a.nodes)h+=l(u,m+1);h+=`${f}} +`,r&&(o+=f.length,o+=2)}else if(a.kind==="comment"){if(h+=`${f}/*${a.value}*/ +`,r){o+=f.length;let u=o;o+=2+a.value.length+2;let p=o;a.dst=[t,u,p],o+=1}}else if(a.kind==="context"||a.kind==="at-root")return"";return h}let s="";for(let a of e)s+=l(a,0);return t.code=s,s}function Ti(e,r){let o=[];return O(e,(t,l)=>{if(r(t))return o=l.path(),o.push(t),V.Stop}),o}function Br(e,r,o,t=new Set){if(t.has(e)||(t.add(e),r.getOptions(e)&24))return!0;{let s=o.get(e)??[];for(let a of s)if(Br(a,r,o,t))return!0}return!1}function Wr(e){return e.split(/[\s,]+/)}var Wt=["calc","min","max","clamp","mod","rem","sin","cos","tan","asin","acos","atan","atan2","pow","sqrt","hypot","log","exp","round"];function He(e){return e.indexOf("(")!==-1&&Wt.some(r=>e.includes(`${r}(`))}function ft(e){if(!Wt.some(s=>e.includes(s)))return e;let r="",o=[],t=null,l=null;for(let s=0;s=48&&a<=57||t!==null&&(a===37||a>=97&&a<=122||a>=65&&a<=90)?t=s:(l=t,t=null),a===40){r+=e[s];let m=s;for(let f=s-1;f>=0;f--){let u=e.charCodeAt(f);if(u>=48&&u<=57)m=f;else if(u>=97&&u<=122)m=f;else break}let h=e.slice(m,s);if(Wt.includes(h)){o.unshift(!0);continue}else if(o[0]&&h===""){o.unshift(!0);continue}o.unshift(!1);continue}else if(a===41)r+=e[s],o.shift();else if(a===44&&o[0]){r+=", ";continue}else{if(a===32&&o[0]&&r.charCodeAt(r.length-1)===32)continue;if((a===43||a===42||a===47||a===45)&&o[0]){let m=r.trimEnd(),h=m.charCodeAt(m.length-1),f=m.charCodeAt(m.length-2),u=e.charCodeAt(s+1);if((h===101||h===69)&&f>=48&&f<=57){r+=e[s];continue}else if(h===43||h===42||h===47||h===45){r+=e[s];continue}else if(h===40||h===44){r+=e[s];continue}else e.charCodeAt(s-1)===32?r+=`${e[s]} `:h>=48&&h<=57||u>=48&&u<=57||h===41||u===40||u===43||u===42||u===47||u===45||l!==null&&l===s-1?r+=` ${e[s]} `:r+=e[s]}else r+=e[s]}}return r}function Ae(e){if(e.indexOf("(")===-1)return Oe(e);let r=q(e);return Bt(r),e=Z(r),e=ft(e),e}function Oe(e,r=!1){let o="";for(let t=0;t0&&l===Ht[r-1]&&r--;break;case 59:if(r===0)return!1;break}}return!0}var Vi=58,Hr=45,qr=97,Gr=122,Zt=/^[a-zA-Z0-9_.%-]+$/;function*Zr(e,r){let o=U(e,":");if(r.theme.prefix){if(o.length===1||o[0]!==r.theme.prefix)return null;o.shift()}let t=o.pop(),l=[];for(let p=o.length-1;p>=0;--p){let c=r.parseVariant(o[p]);if(c===null)return;l.push(c)}let s=!1;t[t.length-1]==="!"?(s=!0,t=t.slice(0,-1)):t[0]==="!"&&(s=!0,t=t.slice(1)),r.utilities.has(t,"static")&&!t.includes("[")&&(yield{kind:"static",root:t,variants:l,important:s,raw:e});let[a,m=null,h]=U(t,"/");if(h)return;let f=m===null?null:qt(m);if(m!==null&&f===null)return;if(a[0]==="["){if(a[a.length-1]!=="]")return;let p=a.charCodeAt(1);if(p!==Hr&&!(p>=qr&&p<=Gr))return;a=a.slice(1,-1);let c=a.indexOf(":");if(c===-1||c===0||c===a.length-1)return;let v=a.slice(0,c),g=Ae(a.slice(c+1));if(!be(g))return;yield{kind:"arbitrary",property:v,value:g,modifier:f,variants:l,important:s,raw:e};return}let u;if(a[a.length-1]==="]"){let p=a.indexOf("-[");if(p===-1)return;let c=a.slice(0,p);if(!r.utilities.has(c,"functional"))return;let v=a.slice(p+1);u=[[c,v]]}else if(a[a.length-1]===")"){let p=a.indexOf("-(");if(p===-1)return;let c=a.slice(0,p);if(!r.utilities.has(c,"functional"))return;let v=a.slice(p+2,-1),g=U(v,":"),b=null;if(g.length===2&&(b=g[0],v=g[1]),v[0]!=="-"||v[1]!=="-"||!be(v))return;u=[[c,b===null?`[var(${v})]`:`[${b}:var(${v})]`]]}else u=Qr(a,p=>r.utilities.has(p,"functional"));for(let[p,c]of u){let v={kind:"functional",root:p,modifier:f,value:null,variants:l,important:s,raw:e};if(c===null){yield v;continue}{let g=c.indexOf("[");if(g!==-1){if(c[c.length-1]!=="]")return;let x=Ae(c.slice(g+1,-1));if(!be(x))continue;let T=null;for(let A=0;A=qr&&w<=Gr))break}if(x.length===0||x.trim().length===0||T==="")continue;v.value={kind:"arbitrary",dataType:T||null,value:x}}else{let x=m===null||v.modifier?.kind==="arbitrary"?null:`${c}/${m}`;if(!Zt.test(c))continue;v.value={kind:"named",value:c,fraction:x}}}yield v}}function qt(e){if(e[0]==="["&&e[e.length-1]==="]"){let r=Ae(e.slice(1,-1));return!be(r)||r.length===0||r.trim().length===0?null:{kind:"arbitrary",value:r}}return e[0]==="("&&e[e.length-1]===")"?(e=e.slice(1,-1),e[0]!=="-"||e[1]!=="-"||!be(e)?null:(e=`var(${e})`,{kind:"arbitrary",value:Ae(e)})):Zt.test(e)?{kind:"named",value:e}:null}function Yr(e,r){if(e[0]==="["&&e[e.length-1]==="]"){if(e[1]==="@"&&e.includes("&"))return null;let o=Ae(e.slice(1,-1));if(!be(o)||o.length===0||o.trim().length===0)return null;let t=o[0]===">"||o[0]==="+"||o[0]==="~";return!t&&o[0]!=="@"&&!o.includes("&")&&(o=`&:is(${o})`),{kind:"arbitrary",selector:o,relative:t}}{let[o,t=null,l]=U(e,"/");if(l)return null;let s=Qr(o,a=>r.variants.has(a));for(let[a,m]of s)switch(r.variants.kind(a)){case"static":return m!==null||t!==null?null:{kind:"static",root:a};case"functional":{let h=t===null?null:qt(t);if(t!==null&&h===null)return null;if(m===null)return{kind:"functional",root:a,modifier:h,value:null};if(m[m.length-1]==="]"){if(m[0]!=="[")continue;let f=Ae(m.slice(1,-1));return!be(f)||f.length===0||f.trim().length===0?null:{kind:"functional",root:a,modifier:h,value:{kind:"arbitrary",value:f}}}if(m[m.length-1]===")"){if(m[0]!=="(")continue;let f=Ae(m.slice(1,-1));return!be(f)||f.length===0||f.trim().length===0||f[0]!=="-"||f[1]!=="-"?null:{kind:"functional",root:a,modifier:h,value:{kind:"arbitrary",value:`var(${f})`}}}if(!Zt.test(m))continue;return{kind:"functional",root:a,modifier:h,value:{kind:"named",value:m}}}case"compound":{if(m===null)return null;t&&(a==="not"||a==="has"||a==="in")&&(m=`${m}/${t}`,t=null);let h=r.parseVariant(m);if(h===null||!r.variants.compoundsWith(a,h))return null;let f=t===null?null:qt(t);return t!==null&&f===null?null:{kind:"compound",root:a,modifier:f,variant:h}}}}return null}function*Qr(e,r){r(e)&&(yield[e,null]);let o=e.lastIndexOf("-");for(;o>0;){let t=e.slice(0,o);if(r(t)){let l=[t,e.slice(o+1)];if(l[1]===""||l[0]==="@"&&r("@")&&e[o]==="-")break;yield l}o=e.lastIndexOf("-",o-1)}e[0]==="@"&&r("@")&&(yield["@",e.slice(1)])}function Jr(e,r){let o=[];for(let l of r.variants)o.unshift(pt(l));e.theme.prefix&&o.unshift(e.theme.prefix);let t="";if(r.kind==="static"&&(t+=r.root),r.kind==="functional"&&(t+=r.root,r.value))if(r.value.kind==="arbitrary"){if(r.value!==null){let l=Yt(r.value.value),s=l?r.value.value.slice(4,-1):r.value.value,[a,m]=l?["(",")"]:["[","]"];r.value.dataType?t+=`-${a}${r.value.dataType}:${_e(s)}${m}`:t+=`-${a}${_e(s)}${m}`}}else r.value.kind==="named"&&(t+=`-${r.value.value}`);return r.kind==="arbitrary"&&(t+=`[${r.property}:${_e(r.value)}]`),(r.kind==="arbitrary"||r.kind==="functional")&&(t+=Xr(r.modifier)),r.important&&(t+="!"),o.push(t),o.join(":")}function Xr(e){if(e===null)return"";let r=Yt(e.value),o=r?e.value.slice(4,-1):e.value,[t,l]=r?["(",")"]:["[","]"];return e.kind==="arbitrary"?`/${t}${_e(o)}${l}`:e.kind==="named"?`/${e.value}`:""}function pt(e){if(e.kind==="static")return e.root;if(e.kind==="arbitrary")return`[${_e(Oi(e.selector))}]`;let r="";if(e.kind==="functional"){r+=e.root;let o=e.root!=="@";if(e.value)if(e.value.kind==="arbitrary"){let t=Yt(e.value.value),l=t?e.value.value.slice(4,-1):e.value.value,[s,a]=t?["(",")"]:["[","]"];r+=`${o?"-":""}${s}${_e(l)}${a}`}else e.value.kind==="named"&&(r+=`${o?"-":""}${e.value.value}`)}return e.kind==="compound"&&(r+=e.root,r+="-",r+=pt(e.variant)),(e.kind==="functional"||e.kind==="compound")&&(r+=Xr(e.modifier)),r}var Ri=new H(e=>{let r=q(e),o=new Set,t=new Set(["~",">","+","-","*","/"]);return O(r,(l,s)=>{if(l.kind==="word"&&t.has(l.value)){let a=s.index;if(a===-1)return;let m=s.siblings[a-1];if(m?.kind!=="separator"||m.value!==" ")return;let h=s.siblings[a+1];if(h?.kind!=="separator"||h.value!==" ")return;let f=s.siblings[a-2];if(f&&t.has(f.value))return;let u=s.siblings[a+2];if(u&&t.has(u.value))return;o.add(m),o.add(h)}else if(l.kind==="separator"&&l.value.length>0&&l.value.trim()==="")(s.siblings[0]===l||s.siblings[s.siblings.length-1]===l)&&o.add(l);else if(l.kind==="separator"&&l.value.trim()===",")l.value=",";else if(l.kind==="function"&&l.value.startsWith("--")){let a=s.index;if(a<=0)return;let m=s.siblings[a-1];if(m?.kind==="separator"&&m.value===",")return;let h=s.siblings[a-2];return h&&!t.has(h.value)?void 0:V.ReplaceSkip({kind:"function",value:"",nodes:[l]})}}),o.size>0&&O(r,l=>{if(o.has(l))return o.delete(l),V.ReplaceSkip([])}),Gt(r),Z(r)});function _e(e){return Ri.get(e)}var Pi=new H(e=>{let r=q(e);return r.length===3&&r[0].kind==="word"&&r[0].value==="&"&&r[1].kind==="separator"&&r[1].value===":"&&r[2].kind==="function"&&r[2].value==="is"?Z(r[2].nodes):e});function Oi(e){return Pi.get(e)}function Gt(e){for(let r of e)switch(r.kind){case"function":{if(r.value==="url"||r.value.endsWith("_url")){r.value=qe(r.value);break}if(r.value==="var"||r.value.endsWith("_var")||r.value==="theme"||r.value.endsWith("_theme")){r.value=qe(r.value);for(let o=0;o{let r=q(e);return r.length===1&&r[0].kind==="function"&&r[0].value==="var"});function Yt(e){return _i.get(e)}function Li(e){throw new Error(`Unexpected value: ${e}`)}function qe(e){return e.replaceAll("_",String.raw`\_`).replaceAll(" ","_")}function $e(e,r,o){if(e===r)return 0;let t=e.indexOf("("),l=r.indexOf("("),s=t===-1?e.replace(/[\d.]+/g,""):e.slice(0,t),a=l===-1?r.replace(/[\d.]+/g,""):r.slice(0,l),m=(s===a?0:s[-+]?(?:\d*\.)?\d+)(?[a-z]+|%)?$/i,Ge=new H(e=>{let r=Ui.exec(e);if(!r)return null;let o=r.groups?.value;if(o===void 0)return null;let t=Number(o);if(Number.isNaN(t))return null;let l=r.groups?.unit;return l===void 0?[t,null]:[t,l]});var eo=new Set(["black","silver","gray","white","maroon","red","purple","fuchsia","green","lime","olive","yellow","navy","blue","teal","aqua","aliceblue","antiquewhite","aqua","aquamarine","azure","beige","bisque","black","blanchedalmond","blue","blueviolet","brown","burlywood","cadetblue","chartreuse","chocolate","coral","cornflowerblue","cornsilk","crimson","cyan","darkblue","darkcyan","darkgoldenrod","darkgray","darkgreen","darkgrey","darkkhaki","darkmagenta","darkolivegreen","darkorange","darkorchid","darkred","darksalmon","darkseagreen","darkslateblue","darkslategray","darkslategrey","darkturquoise","darkviolet","deeppink","deepskyblue","dimgray","dimgrey","dodgerblue","firebrick","floralwhite","forestgreen","fuchsia","gainsboro","ghostwhite","gold","goldenrod","gray","green","greenyellow","grey","honeydew","hotpink","indianred","indigo","ivory","khaki","lavender","lavenderblush","lawngreen","lemonchiffon","lightblue","lightcoral","lightcyan","lightgoldenrodyellow","lightgray","lightgreen","lightgrey","lightpink","lightsalmon","lightseagreen","lightskyblue","lightslategray","lightslategrey","lightsteelblue","lightyellow","lime","limegreen","linen","magenta","maroon","mediumaquamarine","mediumblue","mediumorchid","mediumpurple","mediumseagreen","mediumslateblue","mediumspringgreen","mediumturquoise","mediumvioletred","midnightblue","mintcream","mistyrose","moccasin","navajowhite","navy","oldlace","olive","olivedrab","orange","orangered","orchid","palegoldenrod","palegreen","paleturquoise","palevioletred","papayawhip","peachpuff","peru","pink","plum","powderblue","purple","rebeccapurple","red","rosybrown","royalblue","saddlebrown","salmon","sandybrown","seagreen","seashell","sienna","silver","skyblue","slateblue","slategray","slategrey","snow","springgreen","steelblue","tan","teal","thistle","tomato","turquoise","violet","wheat","white","whitesmoke","yellow","yellowgreen","transparent","currentcolor","canvas","canvastext","linktext","visitedtext","activetext","buttonface","buttontext","buttonborder","field","fieldtext","highlight","highlighttext","selecteditem","selecteditemtext","mark","marktext","graytext","accentcolor","accentcolortext"]),zi=/^(rgba?|hsla?|hwb|color|(ok)?(lab|lch)|light-dark|color-mix|--alpha)\(/i;function to(e){return e.charCodeAt(0)===35||zi.test(e)||eo.has(e.toLowerCase())}function ro(e){return eo.has(e.toLowerCase())}var Ki={color:to,length:Ee,percentage:Qt,ratio:Qi,number:io,integer:R,url:oo,position:tn,"bg-size":rn,"line-width":Ii,image:Mi,"family-name":Bi,"generic-name":Wi,"absolute-size":Hi,"relative-size":qi,angle:ln,vector:sn};function Q(e,r){if(e.startsWith("var("))return null;for(let o of r)if(Ki[o]?.(e))return o;return null}var Di=/^url\(.*\)$/;function oo(e){return Di.test(e)}function Ii(e){return U(e," ").every(r=>Ee(r)||io(r)||r==="thin"||r==="medium"||r==="thick")}var ji=/^(?:element|image|cross-fade|image-set)\(/,Fi=/^(repeating-)?(conic|linear|radial)-gradient\(/;function Mi(e){let r=0;for(let o of U(e,","))if(!o.startsWith("var(")){if(oo(o)){r+=1;continue}if(Fi.test(o)){r+=1;continue}if(ji.test(o)){r+=1;continue}return!1}return r>0}function Wi(e){return e==="serif"||e==="sans-serif"||e==="monospace"||e==="cursive"||e==="fantasy"||e==="system-ui"||e==="ui-serif"||e==="ui-sans-serif"||e==="ui-monospace"||e==="ui-rounded"||e==="math"||e==="emoji"||e==="fangsong"}function Bi(e){let r=0;for(let o of U(e,",")){let t=o.charCodeAt(0);if(t>=48&&t<=57)return!1;o.startsWith("var(")||(r+=1)}return r>0}function Hi(e){return e==="xx-small"||e==="x-small"||e==="small"||e==="medium"||e==="large"||e==="x-large"||e==="xx-large"||e==="xxx-large"}function qi(e){return e==="larger"||e==="smaller"}var we=/[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?/,Gi=new RegExp(`^${we.source}$`);function io(e){return Gi.test(e)||He(e)}var Zi=new RegExp(`^${we.source}%$`);function Qt(e){return Zi.test(e)||He(e)}var Yi=new RegExp(`^${we.source}\\s*/\\s*${we.source}$`);function Qi(e){return Yi.test(e)||He(e)}var Ji=["cm","mm","Q","in","pc","pt","px","em","ex","ch","rem","lh","rlh","vw","vh","vmin","vmax","vb","vi","svw","svh","lvw","lvh","dvw","dvh","cqw","cqh","cqi","cqb","cqmin","cqmax"],Xi=new RegExp(`^${we.source}(${Ji.join("|")})$`),en=/^(--spacing)\(/i;function Ee(e){return Xi.test(e)||en.test(e)||He(e)}function tn(e){let r=0;for(let o of U(e," ")){if(o==="center"||o==="top"||o==="right"||o==="bottom"||o==="left"){r+=1;continue}if(!o.startsWith("var(")){if(Ee(o)||Qt(o)){r+=1;continue}return!1}}return r>0}function rn(e){let r=0;for(let o of U(e,",")){if(o==="cover"||o==="contain"){r+=1;continue}let t=U(o," ");if(t.length!==1&&t.length!==2)return!1;if(t.every(l=>l==="auto"||Ee(l)||Qt(l))){r+=1;continue}}return r>0}var on=["deg","rad","grad","turn"],nn=new RegExp(`^${we.source}(${on.join("|")})$`);function ln(e){return nn.test(e)}var an=new RegExp(`^${we.source} +${we.source} +${we.source}$`);function sn(e){return an.test(e)}function R(e){let r=Number(e);return Number.isInteger(r)&&r>=0&&String(r)===String(e)}function Jt(e){let r=Number(e);return Number.isInteger(r)&&r>0&&String(r)===String(e)}function le(e){return no(e,.25)}function Ce(e){return no(e,.25)}function no(e,r){let o=Number(e);return o>=0&&o%r===0&&String(o)===String(e)}var un=new Set(["inset","inherit","initial","revert","unset"]),cn=new Set(["calc","clamp","max","min","--spacing"]),fn=new Set(["color","color-mix","contrast-color","device-cmyk","hsl","hsla","hwb","lab","lch","light-dark","oklab","oklch","rgb","rgba","--alpha"]),pn=/^-?(\d+|\.\d+)(.*?)$/;function Ze(e,r){function o(l){let s=Z([l]),a=r(s);return q(a)}return U(e,",").map(l=>{l=l.trim();let s=q(l),a=null,m=0,h=0,f=!1;return O(s,u=>{switch(u.kind){case"word":{if(un.has(u.value.toLowerCase()))return V.Continue;if(pn.test(u.value.toLowerCase()))return h++,V.Continue;if(u.value[0]==="#"||ro(u.value))return f=!0,V.ReplaceStop(o(u));a=u,m++;break}case"function":return fn.has(u.value.toLowerCase())?(f=!0,V.ReplaceStop(o(u))):cn.has(u.value.toLowerCase())?(h++,V.Skip):(a=u,m++,V.Skip);case"separator":return V.Continue;default:}}),f?Z(s):h<2?l:m===0?`${l} ${r("currentcolor")}`:(m===1&&O(s,u=>u===a?(f=!0,V.ReplaceStop(o(u))):V.Skip),f?Z(s):l)}).join(", ")}var gt=["0","0.5","1","1.5","2","2.5","3","3.5","4","5","6","7","8","9","10","11","12","14","16","20","24","28","32","36","40","44","48","52","56","60","64","72","80","96"],Xt=class{utilities=new H(()=>[]);completions=new Map;static(r,o){this.utilities.get(r).push({kind:"static",compileFn:o})}functional(r,o,t){this.utilities.get(r).push({kind:"functional",compileFn:o,options:t})}has(r,o){return this.utilities.has(r)&&this.utilities.get(r).some(t=>t.kind===o)}get(r){return this.utilities.has(r)?this.utilities.get(r):[]}getCompletions(r){return this.has(r,"static")?this.completions.get(r)?.()??[{supportsNegative:!1,values:[],modifiers:[]}]:this.completions.get(r)?.()??[]}suggest(r,o){let t=this.completions.get(r);t?this.completions.set(r,()=>[...t?.(),...o?.()]):this.completions.set(r,o)}keys(r){let o=[];for(let[t,l]of this.utilities.entries())for(let s of l)if(s.kind===r){o.push(t);break}return o}};function S(e,r,o){return M("@property",e,[n("syntax",o?`"${o}"`:'"*"'),n("inherits","false"),...r?[n("initial-value",r)]:[]])}function J(e,r){if(r===null)return e;let o=Number(r);return Number.isNaN(o)||(r=`${o*100}%`),r==="100%"?e:`color-mix(in oklab, ${e} ${r}, transparent)`}function ao(e,r){let o=Number(r);return Number.isNaN(o)||(r=`${o*100}%`),`oklab(from ${e} l a b / ${r})`}function ee(e,r,o){if(!r)return e;if(r.kind==="arbitrary")return J(e,r.value);let t=o.resolve(r.value,["--opacity"]);return t?J(e,t):Ce(r.value)?J(e,`${r.value}%`):null}function oe(e,r,o){let t=null;switch(e.value.value){case"inherit":{t="inherit";break}case"transparent":{t="transparent";break}case"current":{t="currentcolor";break}default:{t=r.resolve(e.value.value,o);break}}return t?ee(t,e.modifier,r):null}var so=/(\d+)_(\d+)/g;function uo(e){let r=new Xt;function o(i,d){function*k(y){for(let N of e.keysInNamespaces(y))yield N.replace(so,(_,$,E)=>`${$}.${E}`)}let C=["1/2","1/3","2/3","1/4","2/4","3/4","1/5","2/5","3/5","4/5","1/6","2/6","3/6","4/6","5/6","1/12","2/12","3/12","4/12","5/12","6/12","7/12","8/12","9/12","10/12","11/12"];r.suggest(i,()=>{let y=[];for(let N of d()){if(typeof N=="string"){y.push({values:[N],modifiers:[]});continue}let _=[...N.values??[],...k(N.valueThemeKeys??[])],$=[...N.modifiers??[],...k(N.modifierThemeKeys??[])];N.supportsFractions&&_.push(...C),N.hasDefaultValue&&_.unshift(null),y.push({supportsNegative:N.supportsNegative,values:_,modifiers:$})}return y})}function t(i,d){r.static(i,()=>d.map(k=>typeof k=="function"?k():n(k[0],k[1])))}function l(i,d){d.staticValues&&(d.staticValues=Object.assign(Object.create(null),d.staticValues));function k({negative:C}){return y=>{let N=null,_=null;if(y.value)if(y.value.kind==="arbitrary"){if(y.modifier)return;N=y.value.value,_=y.value.dataType}else{if(N=e.resolve(y.value.fraction??y.value.value,d.themeKeys??[]),N===null&&d.supportsFractions&&y.value.fraction){let[$,E]=U(y.value.fraction,"/");if(!R($)||!R(E))return;N=`calc(${$} / ${E} * 100%)`}if(N===null&&C&&d.handleNegativeBareValue){if(N=d.handleNegativeBareValue(y.value),!N?.includes("/")&&y.modifier)return;if(N!==null)return d.handle(N,null)}if(N===null&&d.handleBareValue&&(N=d.handleBareValue(y.value),!N?.includes("/")&&y.modifier))return;if(N===null&&!C&&d.staticValues&&!y.modifier){let $=d.staticValues[y.value.value];if($)return $.map(re)}}else{if(y.modifier)return;N=d.defaultValue!==void 0?d.defaultValue:e.resolve(null,d.themeKeys??[])}if(N!==null)return d.handle(C?ft(`calc(${N} * -1)`):N,_)}}if(d.supportsNegative&&r.functional(`-${i}`,k({negative:!0})),r.functional(i,k({negative:!1})),o(i,()=>[{supportsNegative:d.supportsNegative,valueThemeKeys:d.themeKeys??[],hasDefaultValue:d.defaultValue!==void 0&&d.defaultValue!==null,supportsFractions:d.supportsFractions}]),d.staticValues&&Object.keys(d.staticValues).length>0){let C=Object.keys(d.staticValues);o(i,()=>[{values:C}])}}function s(i,d){r.functional(i,k=>{if(!k.value)return;let C=null;if(k.value.kind==="arbitrary"?(C=k.value.value,C=ee(C,k.modifier,e)):C=oe(k,e,d.themeKeys),C!==null)return d.handle(C)}),o(i,()=>[{values:["current","inherit","transparent"],valueThemeKeys:d.themeKeys,modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(k,C)=>`${C*5}`)}])}function a(i,d,k,{supportsNegative:C=!1,supportsFractions:y=!1,staticValues:N}={}){C&&r.static(`-${i}-px`,()=>k("-1px")),r.static(`${i}-px`,()=>k("1px")),l(i,{themeKeys:d,supportsFractions:y,supportsNegative:C,defaultValue:null,handleBareValue:({value:_})=>!e.resolve(null,["--spacing"])||!le(_)?null:`--spacing(${_})`,handleNegativeBareValue:({value:_})=>!e.resolve(null,["--spacing"])||!le(_)?null:`--spacing(-${_})`,handle:k,staticValues:N}),o(i,()=>[{values:e.get(["--spacing"])?gt:[],supportsNegative:C,supportsFractions:y,valueThemeKeys:d}])}t("sr-only",[["position","absolute"],["width","1px"],["height","1px"],["padding","0"],["margin","-1px"],["overflow","hidden"],["clip-path","inset(50%)"],["white-space","nowrap"],["border-width","0"]]),t("not-sr-only",[["position","static"],["width","auto"],["height","auto"],["padding","0"],["margin","0"],["overflow","visible"],["clip-path","none"],["white-space","normal"]]),t("pointer-events-none",[["pointer-events","none"]]),t("pointer-events-auto",[["pointer-events","auto"]]),t("visible",[["visibility","visible"]]),t("invisible",[["visibility","hidden"]]),t("collapse",[["visibility","collapse"]]),t("static",[["position","static"]]),t("fixed",[["position","fixed"]]),t("absolute",[["position","absolute"]]),t("relative",[["position","relative"]]),t("sticky",[["position","sticky"]]);for(let[i,d]of[["inset","inset"],["inset-x","inset-inline"],["inset-y","inset-block"],["inset-s","inset-inline-start"],["inset-e","inset-inline-end"],["inset-bs","inset-block-start"],["inset-be","inset-block-end"],["top","top"],["right","right"],["bottom","bottom"],["left","left"]])t(`${i}-auto`,[[d,"auto"]]),t(`${i}-full`,[[d,"100%"]]),t(`-${i}-full`,[[d,"-100%"]]),a(i,["--inset","--spacing"],k=>[n(d,k)],{supportsNegative:!0,supportsFractions:!0});t("isolate",[["isolation","isolate"]]),t("isolation-auto",[["isolation","auto"]]),l("z",{supportsNegative:!0,handleBareValue:({value:i})=>R(i)?i:null,themeKeys:["--z-index"],handle:i=>[n("z-index",i)],staticValues:{auto:[n("z-index","auto")]}}),o("z",()=>[{supportsNegative:!0,values:["0","10","20","30","40","50"],valueThemeKeys:["--z-index"]}]),l("order",{supportsNegative:!0,handleBareValue:({value:i})=>R(i)?i:null,themeKeys:["--order"],handle:i=>[n("order",i)],staticValues:{first:[n("order","-9999")],last:[n("order","9999")]}}),o("order",()=>[{supportsNegative:!0,values:Array.from({length:12},(i,d)=>`${d+1}`),valueThemeKeys:["--order"]}]),l("col",{supportsNegative:!0,handleBareValue:({value:i})=>R(i)?i:null,themeKeys:["--grid-column"],handle:i=>[n("grid-column",i)],staticValues:{auto:[n("grid-column","auto")]}}),l("col-span",{handleBareValue:({value:i})=>R(i)?i:null,handle:i=>[n("grid-column",`span ${i} / span ${i}`)],staticValues:{full:[n("grid-column","1 / -1")]}}),l("col-start",{supportsNegative:!0,handleBareValue:({value:i})=>R(i)?i:null,themeKeys:["--grid-column-start"],handle:i=>[n("grid-column-start",i)],staticValues:{auto:[n("grid-column-start","auto")]}}),l("col-end",{supportsNegative:!0,handleBareValue:({value:i})=>R(i)?i:null,themeKeys:["--grid-column-end"],handle:i=>[n("grid-column-end",i)],staticValues:{auto:[n("grid-column-end","auto")]}}),o("col-span",()=>[{values:Array.from({length:12},(i,d)=>`${d+1}`),valueThemeKeys:[]}]),o("col-start",()=>[{supportsNegative:!0,values:Array.from({length:13},(i,d)=>`${d+1}`),valueThemeKeys:["--grid-column-start"]}]),o("col-end",()=>[{supportsNegative:!0,values:Array.from({length:13},(i,d)=>`${d+1}`),valueThemeKeys:["--grid-column-end"]}]),l("row",{supportsNegative:!0,handleBareValue:({value:i})=>R(i)?i:null,themeKeys:["--grid-row"],handle:i=>[n("grid-row",i)],staticValues:{auto:[n("grid-row","auto")]}}),l("row-span",{themeKeys:[],handleBareValue:({value:i})=>R(i)?i:null,handle:i=>[n("grid-row",`span ${i} / span ${i}`)],staticValues:{full:[n("grid-row","1 / -1")]}}),l("row-start",{supportsNegative:!0,handleBareValue:({value:i})=>R(i)?i:null,themeKeys:["--grid-row-start"],handle:i=>[n("grid-row-start",i)],staticValues:{auto:[n("grid-row-start","auto")]}}),l("row-end",{supportsNegative:!0,handleBareValue:({value:i})=>R(i)?i:null,themeKeys:["--grid-row-end"],handle:i=>[n("grid-row-end",i)],staticValues:{auto:[n("grid-row-end","auto")]}}),o("row-span",()=>[{values:Array.from({length:12},(i,d)=>`${d+1}`),valueThemeKeys:[]}]),o("row-start",()=>[{supportsNegative:!0,values:Array.from({length:13},(i,d)=>`${d+1}`),valueThemeKeys:["--grid-row-start"]}]),o("row-end",()=>[{supportsNegative:!0,values:Array.from({length:13},(i,d)=>`${d+1}`),valueThemeKeys:["--grid-row-end"]}]),t("float-start",[["float","inline-start"]]),t("float-end",[["float","inline-end"]]),t("float-right",[["float","right"]]),t("float-left",[["float","left"]]),t("float-none",[["float","none"]]),t("clear-start",[["clear","inline-start"]]),t("clear-end",[["clear","inline-end"]]),t("clear-right",[["clear","right"]]),t("clear-left",[["clear","left"]]),t("clear-both",[["clear","both"]]),t("clear-none",[["clear","none"]]);for(let[i,d]of[["m","margin"],["mx","margin-inline"],["my","margin-block"],["ms","margin-inline-start"],["me","margin-inline-end"],["mbs","margin-block-start"],["mbe","margin-block-end"],["mt","margin-top"],["mr","margin-right"],["mb","margin-bottom"],["ml","margin-left"]])t(`${i}-auto`,[[d,"auto"]]),a(i,["--margin","--spacing"],k=>[n(d,k)],{supportsNegative:!0});t("box-border",[["box-sizing","border-box"]]),t("box-content",[["box-sizing","content-box"]]),l("line-clamp",{themeKeys:["--line-clamp"],handleBareValue:({value:i})=>R(i)?i:null,handle:i=>[n("overflow","hidden"),n("display","-webkit-box"),n("-webkit-box-orient","vertical"),n("-webkit-line-clamp",i)],staticValues:{none:[n("overflow","visible"),n("display","block"),n("-webkit-box-orient","horizontal"),n("-webkit-line-clamp","unset")]}}),o("line-clamp",()=>[{values:["1","2","3","4","5","6"],valueThemeKeys:["--line-clamp"]}]),t("block",[["display","block"]]),t("inline-block",[["display","inline-block"]]),t("inline",[["display","inline"]]),t("hidden",[["display","none"]]),t("inline-flex",[["display","inline-flex"]]),t("table",[["display","table"]]),t("inline-table",[["display","inline-table"]]),t("table-caption",[["display","table-caption"]]),t("table-cell",[["display","table-cell"]]),t("table-column",[["display","table-column"]]),t("table-column-group",[["display","table-column-group"]]),t("table-footer-group",[["display","table-footer-group"]]),t("table-header-group",[["display","table-header-group"]]),t("table-row-group",[["display","table-row-group"]]),t("table-row",[["display","table-row"]]),t("flow-root",[["display","flow-root"]]),t("flex",[["display","flex"]]),t("grid",[["display","grid"]]),t("inline-grid",[["display","inline-grid"]]),t("contents",[["display","contents"]]),t("list-item",[["display","list-item"]]),t("field-sizing-content",[["field-sizing","content"]]),t("field-sizing-fixed",[["field-sizing","fixed"]]),l("aspect",{themeKeys:["--aspect"],handleBareValue:({fraction:i})=>{if(i===null)return null;let[d,k]=U(i,"/");return!le(d)||!le(k)?null:i},handle:i=>[n("aspect-ratio",i)],staticValues:{auto:[n("aspect-ratio","auto")],square:[n("aspect-ratio","1 / 1")]}});for(let[i,d]of[["full","100%"],["svw","100svw"],["lvw","100lvw"],["dvw","100dvw"],["svh","100svh"],["lvh","100lvh"],["dvh","100dvh"],["min","min-content"],["max","max-content"],["fit","fit-content"]])t(`size-${i}`,[["--tw-sort","size"],["width",d],["height",d]]),t(`w-${i}`,[["width",d]]),t(`h-${i}`,[["height",d]]),t(`min-w-${i}`,[["min-width",d]]),t(`min-h-${i}`,[["min-height",d]]),t(`max-w-${i}`,[["max-width",d]]),t(`max-h-${i}`,[["max-height",d]]);t("size-auto",[["--tw-sort","size"],["width","auto"],["height","auto"]]),t("w-auto",[["width","auto"]]),t("h-auto",[["height","auto"]]),t("min-w-auto",[["min-width","auto"]]),t("min-h-auto",[["min-height","auto"]]),t("h-lh",[["height","1lh"]]),t("min-h-lh",[["min-height","1lh"]]),t("max-h-lh",[["max-height","1lh"]]),t("w-screen",[["width","100vw"]]),t("min-w-screen",[["min-width","100vw"]]),t("max-w-screen",[["max-width","100vw"]]),t("h-screen",[["height","100vh"]]),t("min-h-screen",[["min-height","100vh"]]),t("max-h-screen",[["max-height","100vh"]]),t("max-w-none",[["max-width","none"]]),t("max-h-none",[["max-height","none"]]),a("size",["--size","--spacing"],i=>[n("--tw-sort","size"),n("width",i),n("height",i)],{supportsFractions:!0});for(let[i,d,k]of[["w",["--width","--spacing","--container"],"width"],["min-w",["--min-width","--spacing","--container"],"min-width"],["max-w",["--max-width","--spacing","--container"],"max-width"],["h",["--height","--spacing"],"height"],["min-h",["--min-height","--height","--spacing"],"min-height"],["max-h",["--max-height","--height","--spacing"],"max-height"]])a(i,d,C=>[n(k,C)],{supportsFractions:!0});for(let[i,d]of[["full","100%"],["min","min-content"],["max","max-content"],["fit","fit-content"]])t(`inline-${i}`,[["inline-size",d]]),t(`block-${i}`,[["block-size",d]]),t(`min-inline-${i}`,[["min-inline-size",d]]),t(`min-block-${i}`,[["min-block-size",d]]),t(`max-inline-${i}`,[["max-inline-size",d]]),t(`max-block-${i}`,[["max-block-size",d]]);for(let[i,d]of[["svw","100svw"],["lvw","100lvw"],["dvw","100dvw"]])t(`inline-${i}`,[["inline-size",d]]),t(`min-inline-${i}`,[["min-inline-size",d]]),t(`max-inline-${i}`,[["max-inline-size",d]]);for(let[i,d]of[["svh","100svh"],["lvh","100lvh"],["dvh","100dvh"]])t(`block-${i}`,[["block-size",d]]),t(`min-block-${i}`,[["min-block-size",d]]),t(`max-block-${i}`,[["max-block-size",d]]);t("inline-auto",[["inline-size","auto"]]),t("block-auto",[["block-size","auto"]]),t("min-inline-auto",[["min-inline-size","auto"]]),t("min-block-auto",[["min-block-size","auto"]]),t("block-lh",[["block-size","1lh"]]),t("min-block-lh",[["min-block-size","1lh"]]),t("max-block-lh",[["max-block-size","1lh"]]),t("inline-screen",[["inline-size","100vw"]]),t("min-inline-screen",[["min-inline-size","100vw"]]),t("max-inline-screen",[["max-inline-size","100vw"]]),t("block-screen",[["block-size","100vh"]]),t("min-block-screen",[["min-block-size","100vh"]]),t("max-block-screen",[["max-block-size","100vh"]]),t("max-inline-none",[["max-inline-size","none"]]),t("max-block-none",[["max-block-size","none"]]);for(let[i,d,k]of[["inline",["--spacing","--container"],"inline-size"],["min-inline",["--spacing","--container"],"min-inline-size"],["max-inline",["--spacing","--container"],"max-inline-size"],["block",["--spacing"],"block-size"],["min-block",["--spacing"],"min-block-size"],["max-block",["--spacing"],"max-block-size"]])a(i,d,C=>[n(k,C)],{supportsFractions:!0});r.static("container",()=>{let i=[...e.namespace("--breakpoint").values()];i.sort((k,C)=>$e(k,C,"asc"));let d=[n("--tw-sort","--tw-container-component"),n("width","100%")];for(let k of i)d.push(M("@media",`(width >= ${k})`,[n("max-width",k)]));return d}),t("flex-auto",[["flex","auto"]]),t("flex-initial",[["flex","0 auto"]]),t("flex-none",[["flex","none"]]),r.functional("flex",i=>{if(i.value){if(i.value.kind==="arbitrary")return i.modifier?void 0:[n("flex",i.value.value)];if(i.value.fraction){let[d,k]=U(i.value.fraction,"/");return!R(d)||!R(k)?void 0:[n("flex",`calc(${i.value.fraction} * 100%)`)]}if(R(i.value.value))return i.modifier?void 0:[n("flex",i.value.value)]}}),o("flex",()=>[{supportsFractions:!0},{values:Array.from({length:12},(i,d)=>`${d+1}`)}]),l("shrink",{defaultValue:"1",handleBareValue:({value:i})=>R(i)?i:null,handle:i=>[n("flex-shrink",i)]}),l("grow",{defaultValue:"1",handleBareValue:({value:i})=>R(i)?i:null,handle:i=>[n("flex-grow",i)]}),o("shrink",()=>[{values:["0"],valueThemeKeys:[],hasDefaultValue:!0}]),o("grow",()=>[{values:["0"],valueThemeKeys:[],hasDefaultValue:!0}]),t("basis-auto",[["flex-basis","auto"]]),t("basis-full",[["flex-basis","100%"]]),a("basis",["--flex-basis","--spacing","--container"],i=>[n("flex-basis",i)],{supportsFractions:!0}),t("table-auto",[["table-layout","auto"]]),t("table-fixed",[["table-layout","fixed"]]),t("caption-top",[["caption-side","top"]]),t("caption-bottom",[["caption-side","bottom"]]),t("border-collapse",[["border-collapse","collapse"]]),t("border-separate",[["border-collapse","separate"]]);let m=()=>F([S("--tw-border-spacing-x","0",""),S("--tw-border-spacing-y","0","")]);a("border-spacing",["--border-spacing","--spacing"],i=>[m(),n("--tw-border-spacing-x",i),n("--tw-border-spacing-y",i),n("border-spacing","var(--tw-border-spacing-x) var(--tw-border-spacing-y)")]),a("border-spacing-x",["--border-spacing","--spacing"],i=>[m(),n("--tw-border-spacing-x",i),n("border-spacing","var(--tw-border-spacing-x) var(--tw-border-spacing-y)")]),a("border-spacing-y",["--border-spacing","--spacing"],i=>[m(),n("--tw-border-spacing-y",i),n("border-spacing","var(--tw-border-spacing-x) var(--tw-border-spacing-y)")]),l("origin",{themeKeys:["--transform-origin"],handle:i=>[n("transform-origin",i)],staticValues:{center:[n("transform-origin","center")],top:[n("transform-origin","top")],"top-right":[n("transform-origin","100% 0")],right:[n("transform-origin","100%")],"bottom-right":[n("transform-origin","100% 100%")],bottom:[n("transform-origin","bottom")],"bottom-left":[n("transform-origin","0 100%")],left:[n("transform-origin","0")],"top-left":[n("transform-origin","0 0")]}}),l("perspective-origin",{themeKeys:["--perspective-origin"],handle:i=>[n("perspective-origin",i)],staticValues:{center:[n("perspective-origin","center")],top:[n("perspective-origin","top")],"top-right":[n("perspective-origin","100% 0")],right:[n("perspective-origin","100%")],"bottom-right":[n("perspective-origin","100% 100%")],bottom:[n("perspective-origin","bottom")],"bottom-left":[n("perspective-origin","0 100%")],left:[n("perspective-origin","0")],"top-left":[n("perspective-origin","0 0")]}}),l("perspective",{themeKeys:["--perspective"],handle:i=>[n("perspective",i)],staticValues:{none:[n("perspective","none")]}});let h=()=>F([S("--tw-translate-x","0"),S("--tw-translate-y","0"),S("--tw-translate-z","0")]);t("translate-none",[["translate","none"]]),t("-translate-full",[h,["--tw-translate-x","-100%"],["--tw-translate-y","-100%"],["translate","var(--tw-translate-x) var(--tw-translate-y)"]]),t("translate-full",[h,["--tw-translate-x","100%"],["--tw-translate-y","100%"],["translate","var(--tw-translate-x) var(--tw-translate-y)"]]),a("translate",["--translate","--spacing"],i=>[h(),n("--tw-translate-x",i),n("--tw-translate-y",i),n("translate","var(--tw-translate-x) var(--tw-translate-y)")],{supportsNegative:!0,supportsFractions:!0});for(let i of["x","y"])t(`-translate-${i}-full`,[h,[`--tw-translate-${i}`,"-100%"],["translate","var(--tw-translate-x) var(--tw-translate-y)"]]),t(`translate-${i}-full`,[h,[`--tw-translate-${i}`,"100%"],["translate","var(--tw-translate-x) var(--tw-translate-y)"]]),a(`translate-${i}`,["--translate","--spacing"],d=>[h(),n(`--tw-translate-${i}`,d),n("translate","var(--tw-translate-x) var(--tw-translate-y)")],{supportsNegative:!0,supportsFractions:!0});a("translate-z",["--translate","--spacing"],i=>[h(),n("--tw-translate-z",i),n("translate","var(--tw-translate-x) var(--tw-translate-y) var(--tw-translate-z)")],{supportsNegative:!0}),t("translate-3d",[h,["translate","var(--tw-translate-x) var(--tw-translate-y) var(--tw-translate-z)"]]);let f=()=>F([S("--tw-scale-x","1"),S("--tw-scale-y","1"),S("--tw-scale-z","1")]);t("scale-none",[["scale","none"]]);function u({negative:i}){return d=>{if(!d.value||d.modifier)return;let k;return d.value.kind==="arbitrary"?(k=d.value.value,k=i?`calc(${k} * -1)`:k,[n("scale",k)]):(k=e.resolve(d.value.value,["--scale"]),!k&&R(d.value.value)&&(k=`${d.value.value}%`),k?(k=i?`calc(${k} * -1)`:k,[f(),n("--tw-scale-x",k),n("--tw-scale-y",k),n("--tw-scale-z",k),n("scale","var(--tw-scale-x) var(--tw-scale-y)")]):void 0)}}r.functional("-scale",u({negative:!0})),r.functional("scale",u({negative:!1})),o("scale",()=>[{supportsNegative:!0,values:["0","50","75","90","95","100","105","110","125","150","200"],valueThemeKeys:["--scale"]}]);for(let i of["x","y","z"])l(`scale-${i}`,{supportsNegative:!0,themeKeys:["--scale"],handleBareValue:({value:d})=>R(d)?`${d}%`:null,handle:d=>[f(),n(`--tw-scale-${i}`,d),n("scale",`var(--tw-scale-x) var(--tw-scale-y)${i==="z"?" var(--tw-scale-z)":""}`)]}),o(`scale-${i}`,()=>[{supportsNegative:!0,values:["0","50","75","90","95","100","105","110","125","150","200"],valueThemeKeys:["--scale"]}]);t("scale-3d",[f,["scale","var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)"]]),t("rotate-none",[["rotate","none"]]);function p({negative:i}){return d=>{if(!d.value||d.modifier)return;let k;if(d.value.kind==="arbitrary"){k=d.value.value;let C=d.value.dataType??Q(k,["angle","vector"]);if(C==="vector")return[n("rotate",`${k} var(--tw-rotate)`)];if(C!=="angle")return[n("rotate",i?`calc(${k} * -1)`:k)]}else if(k=e.resolve(d.value.value,["--rotate"]),!k&&R(d.value.value)&&(k=`${d.value.value}deg`),!k)return;return[n("rotate",i?`calc(${k} * -1)`:k)]}}r.functional("-rotate",p({negative:!0})),r.functional("rotate",p({negative:!1})),o("rotate",()=>[{supportsNegative:!0,values:["0","1","2","3","6","12","45","90","180"],valueThemeKeys:["--rotate"]}]);{let i=["var(--tw-rotate-x,)","var(--tw-rotate-y,)","var(--tw-rotate-z,)","var(--tw-skew-x,)","var(--tw-skew-y,)"].join(" "),d=()=>F([S("--tw-rotate-x"),S("--tw-rotate-y"),S("--tw-rotate-z"),S("--tw-skew-x"),S("--tw-skew-y")]);for(let k of["x","y","z"])l(`rotate-${k}`,{supportsNegative:!0,themeKeys:["--rotate"],handleBareValue:({value:C})=>R(C)?`${C}deg`:null,handle:C=>[d(),n(`--tw-rotate-${k}`,`rotate${k.toUpperCase()}(${C})`),n("transform",i)]}),o(`rotate-${k}`,()=>[{supportsNegative:!0,values:["0","1","2","3","6","12","45","90","180"],valueThemeKeys:["--rotate"]}]);l("skew",{supportsNegative:!0,themeKeys:["--skew"],handleBareValue:({value:k})=>R(k)?`${k}deg`:null,handle:k=>[d(),n("--tw-skew-x",`skewX(${k})`),n("--tw-skew-y",`skewY(${k})`),n("transform",i)]}),l("skew-x",{supportsNegative:!0,themeKeys:["--skew"],handleBareValue:({value:k})=>R(k)?`${k}deg`:null,handle:k=>[d(),n("--tw-skew-x",`skewX(${k})`),n("transform",i)]}),l("skew-y",{supportsNegative:!0,themeKeys:["--skew"],handleBareValue:({value:k})=>R(k)?`${k}deg`:null,handle:k=>[d(),n("--tw-skew-y",`skewY(${k})`),n("transform",i)]}),o("skew",()=>[{supportsNegative:!0,values:["0","1","2","3","6","12"],valueThemeKeys:["--skew"]}]),o("skew-x",()=>[{supportsNegative:!0,values:["0","1","2","3","6","12"],valueThemeKeys:["--skew"]}]),o("skew-y",()=>[{supportsNegative:!0,values:["0","1","2","3","6","12"],valueThemeKeys:["--skew"]}]),r.functional("transform",k=>{if(k.modifier)return;let C=null;if(k.value?k.value.kind==="arbitrary"&&(C=k.value.value):C=i,C!==null)return[d(),n("transform",C)]}),o("transform",()=>[{hasDefaultValue:!0}]),t("transform-cpu",[["transform",i]]),t("transform-gpu",[["transform",`translateZ(0) ${i}`]]),t("transform-none",[["transform","none"]])}l("zoom",{handleBareValue:({value:i})=>R(i)?`${i}%`:null,handle:i=>[n("zoom",i)]}),o("zoom",()=>[{values:["50","75","90","95","100","105","110","125","150","200"]}]),t("transform-flat",[["transform-style","flat"]]),t("transform-3d",[["transform-style","preserve-3d"]]),t("transform-content",[["transform-box","content-box"]]),t("transform-border",[["transform-box","border-box"]]),t("transform-fill",[["transform-box","fill-box"]]),t("transform-stroke",[["transform-box","stroke-box"]]),t("transform-view",[["transform-box","view-box"]]),t("backface-visible",[["backface-visibility","visible"]]),t("backface-hidden",[["backface-visibility","hidden"]]);for(let i of["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out"])t(`cursor-${i}`,[["cursor",i]]);l("cursor",{themeKeys:["--cursor"],handle:i=>[n("cursor",i)]});for(let i of["auto","none","manipulation"])t(`touch-${i}`,[["touch-action",i]]);let c=()=>F([S("--tw-pan-x"),S("--tw-pan-y"),S("--tw-pinch-zoom")]);for(let i of["x","left","right"])t(`touch-pan-${i}`,[c,["--tw-pan-x",`pan-${i}`],["touch-action","var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)"]]);for(let i of["y","up","down"])t(`touch-pan-${i}`,[c,["--tw-pan-y",`pan-${i}`],["touch-action","var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)"]]);t("touch-pinch-zoom",[c,["--tw-pinch-zoom","pinch-zoom"],["touch-action","var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)"]]);for(let i of["none","text","all","auto"])t(`select-${i}`,[["-webkit-user-select",i],["user-select",i]]);t("resize-none",[["resize","none"]]),t("resize-x",[["resize","horizontal"]]),t("resize-y",[["resize","vertical"]]),t("resize",[["resize","both"]]),t("snap-none",[["scroll-snap-type","none"]]);let v=()=>F([S("--tw-scroll-snap-strictness","proximity","*")]);for(let i of["x","y","both"])t(`snap-${i}`,[v,["scroll-snap-type",`${i} var(--tw-scroll-snap-strictness)`]]);t("snap-mandatory",[v,["--tw-scroll-snap-strictness","mandatory"]]),t("snap-proximity",[v,["--tw-scroll-snap-strictness","proximity"]]),t("snap-align-none",[["scroll-snap-align","none"]]),t("snap-start",[["scroll-snap-align","start"]]),t("snap-end",[["scroll-snap-align","end"]]),t("snap-center",[["scroll-snap-align","center"]]),t("snap-normal",[["scroll-snap-stop","normal"]]),t("snap-always",[["scroll-snap-stop","always"]]);for(let[i,d]of[["scroll-m","scroll-margin"],["scroll-mx","scroll-margin-inline"],["scroll-my","scroll-margin-block"],["scroll-ms","scroll-margin-inline-start"],["scroll-me","scroll-margin-inline-end"],["scroll-mbs","scroll-margin-block-start"],["scroll-mbe","scroll-margin-block-end"],["scroll-mt","scroll-margin-top"],["scroll-mr","scroll-margin-right"],["scroll-mb","scroll-margin-bottom"],["scroll-ml","scroll-margin-left"]])a(i,["--scroll-margin","--spacing"],k=>[n(d,k)],{supportsNegative:!0});for(let[i,d]of[["scroll-p","scroll-padding"],["scroll-px","scroll-padding-inline"],["scroll-py","scroll-padding-block"],["scroll-ps","scroll-padding-inline-start"],["scroll-pe","scroll-padding-inline-end"],["scroll-pbs","scroll-padding-block-start"],["scroll-pbe","scroll-padding-block-end"],["scroll-pt","scroll-padding-top"],["scroll-pr","scroll-padding-right"],["scroll-pb","scroll-padding-bottom"],["scroll-pl","scroll-padding-left"]])a(i,["--scroll-padding","--spacing"],k=>[n(d,k)]);t("list-inside",[["list-style-position","inside"]]),t("list-outside",[["list-style-position","outside"]]),l("list",{themeKeys:["--list-style-type"],handle:i=>[n("list-style-type",i)],staticValues:{none:[n("list-style-type","none")],disc:[n("list-style-type","disc")],decimal:[n("list-style-type","decimal")]}}),l("list-image",{themeKeys:["--list-style-image"],handle:i=>[n("list-style-image",i)],staticValues:{none:[n("list-style-image","none")]}}),t("appearance-none",[["appearance","none"]]),t("appearance-auto",[["appearance","auto"]]),t("scheme-normal",[["color-scheme","normal"]]),t("scheme-dark",[["color-scheme","dark"]]),t("scheme-light",[["color-scheme","light"]]),t("scheme-light-dark",[["color-scheme","light dark"]]),t("scheme-only-dark",[["color-scheme","only dark"]]),t("scheme-only-light",[["color-scheme","only light"]]),l("columns",{themeKeys:["--columns","--container"],handleBareValue:({value:i})=>R(i)?i:null,handle:i=>[n("columns",i)],staticValues:{auto:[n("columns","auto")]}}),o("columns",()=>[{values:Array.from({length:12},(i,d)=>`${d+1}`),valueThemeKeys:["--columns","--container"]}]);for(let i of["auto","avoid","all","avoid-page","page","left","right","column"])t(`break-before-${i}`,[["break-before",i]]);for(let i of["auto","avoid","avoid-page","avoid-column"])t(`break-inside-${i}`,[["break-inside",i]]);for(let i of["auto","avoid","all","avoid-page","page","left","right","column"])t(`break-after-${i}`,[["break-after",i]]);t("grid-flow-row",[["grid-auto-flow","row"]]),t("grid-flow-col",[["grid-auto-flow","column"]]),t("grid-flow-dense",[["grid-auto-flow","dense"]]),t("grid-flow-row-dense",[["grid-auto-flow","row dense"]]),t("grid-flow-col-dense",[["grid-auto-flow","column dense"]]),l("auto-cols",{themeKeys:["--grid-auto-columns"],handleBareValue:({value:i})=>!e.resolve(null,["--spacing"])||!le(i)?null:`--spacing(${i})`,handle:i=>[n("grid-auto-columns",i)],staticValues:{auto:[n("grid-auto-columns","auto")],min:[n("grid-auto-columns","min-content")],max:[n("grid-auto-columns","max-content")],fr:[n("grid-auto-columns","minmax(0, 1fr)")]}}),l("auto-rows",{themeKeys:["--grid-auto-rows"],handleBareValue:({value:i})=>!e.resolve(null,["--spacing"])||!le(i)?null:`--spacing(${i})`,handle:i=>[n("grid-auto-rows",i)],staticValues:{auto:[n("grid-auto-rows","auto")],min:[n("grid-auto-rows","min-content")],max:[n("grid-auto-rows","max-content")],fr:[n("grid-auto-rows","minmax(0, 1fr)")]}}),l("grid-cols",{themeKeys:["--grid-template-columns"],handleBareValue:({value:i})=>Jt(i)?`repeat(${i}, minmax(0, 1fr))`:null,handle:i=>[n("grid-template-columns",i)],staticValues:{none:[n("grid-template-columns","none")],subgrid:[n("grid-template-columns","subgrid")]}}),l("grid-rows",{themeKeys:["--grid-template-rows"],handleBareValue:({value:i})=>Jt(i)?`repeat(${i}, minmax(0, 1fr))`:null,handle:i=>[n("grid-template-rows",i)],staticValues:{none:[n("grid-template-rows","none")],subgrid:[n("grid-template-rows","subgrid")]}}),o("grid-cols",()=>[{values:Array.from({length:12},(i,d)=>`${d+1}`),valueThemeKeys:["--grid-template-columns"]}]),o("grid-rows",()=>[{values:Array.from({length:12},(i,d)=>`${d+1}`),valueThemeKeys:["--grid-template-rows"]}]),t("flex-row",[["flex-direction","row"]]),t("flex-row-reverse",[["flex-direction","row-reverse"]]),t("flex-col",[["flex-direction","column"]]),t("flex-col-reverse",[["flex-direction","column-reverse"]]),t("flex-wrap",[["flex-wrap","wrap"]]),t("flex-nowrap",[["flex-wrap","nowrap"]]),t("flex-wrap-reverse",[["flex-wrap","wrap-reverse"]]),t("place-content-center",[["place-content","center"]]),t("place-content-start",[["place-content","start"]]),t("place-content-end",[["place-content","end"]]),t("place-content-center-safe",[["place-content","safe center"]]),t("place-content-end-safe",[["place-content","safe end"]]),t("place-content-between",[["place-content","space-between"]]),t("place-content-around",[["place-content","space-around"]]),t("place-content-evenly",[["place-content","space-evenly"]]),t("place-content-baseline",[["place-content","baseline"]]),t("place-content-stretch",[["place-content","stretch"]]),t("place-items-center",[["place-items","center"]]),t("place-items-start",[["place-items","start"]]),t("place-items-end",[["place-items","end"]]),t("place-items-center-safe",[["place-items","safe center"]]),t("place-items-end-safe",[["place-items","safe end"]]),t("place-items-baseline",[["place-items","baseline"]]),t("place-items-stretch",[["place-items","stretch"]]),t("content-normal",[["align-content","normal"]]),t("content-center",[["align-content","center"]]),t("content-start",[["align-content","flex-start"]]),t("content-end",[["align-content","flex-end"]]),t("content-center-safe",[["align-content","safe center"]]),t("content-end-safe",[["align-content","safe flex-end"]]),t("content-between",[["align-content","space-between"]]),t("content-around",[["align-content","space-around"]]),t("content-evenly",[["align-content","space-evenly"]]),t("content-baseline",[["align-content","baseline"]]),t("content-stretch",[["align-content","stretch"]]),t("items-center",[["align-items","center"]]),t("items-start",[["align-items","flex-start"]]),t("items-end",[["align-items","flex-end"]]),t("items-center-safe",[["align-items","safe center"]]),t("items-end-safe",[["align-items","safe flex-end"]]),t("items-baseline",[["align-items","baseline"]]),t("items-baseline-last",[["align-items","last baseline"]]),t("items-stretch",[["align-items","stretch"]]),t("justify-normal",[["justify-content","normal"]]),t("justify-center",[["justify-content","center"]]),t("justify-start",[["justify-content","flex-start"]]),t("justify-end",[["justify-content","flex-end"]]),t("justify-center-safe",[["justify-content","safe center"]]),t("justify-end-safe",[["justify-content","safe flex-end"]]),t("justify-between",[["justify-content","space-between"]]),t("justify-around",[["justify-content","space-around"]]),t("justify-evenly",[["justify-content","space-evenly"]]),t("justify-baseline",[["justify-content","baseline"]]),t("justify-stretch",[["justify-content","stretch"]]),t("justify-items-normal",[["justify-items","normal"]]),t("justify-items-center",[["justify-items","center"]]),t("justify-items-start",[["justify-items","start"]]),t("justify-items-end",[["justify-items","end"]]),t("justify-items-center-safe",[["justify-items","safe center"]]),t("justify-items-end-safe",[["justify-items","safe end"]]),t("justify-items-stretch",[["justify-items","stretch"]]),a("gap",["--gap","--spacing"],i=>[n("gap",i)]),a("gap-x",["--gap","--spacing"],i=>[n("column-gap",i)]),a("gap-y",["--gap","--spacing"],i=>[n("row-gap",i)]),a("space-x",["--space","--spacing"],i=>{let d=(()=>{if(i==="--spacing(0)"||i==="--spacing(-0)")return!0;let k=Ge.get(i);return!!(k&&k[0]===0&&(k[1]===null||Ee(i)))})();return[F([S("--tw-space-x-reverse","0")]),G(":where(& > :not(:last-child))",[n("--tw-sort","row-gap"),n("--tw-space-x-reverse","0"),n("margin-inline-start",d?"0":`calc(${i} * var(--tw-space-x-reverse))`),n("margin-inline-end",d?"0":`calc(${i} * calc(1 - var(--tw-space-x-reverse)))`)])]},{supportsNegative:!0}),a("space-y",["--space","--spacing"],i=>{let d=(()=>{if(i==="--spacing(0)"||i==="--spacing(-0)")return!0;let k=Ge.get(i);return!!(k&&k[0]===0&&(k[1]===null||Ee(i)))})();return[F([S("--tw-space-y-reverse","0")]),G(":where(& > :not(:last-child))",[n("--tw-sort","column-gap"),n("--tw-space-y-reverse","0"),n("margin-block-start",d?"0":`calc(${i} * var(--tw-space-y-reverse))`),n("margin-block-end",d?"0":`calc(${i} * calc(1 - var(--tw-space-y-reverse)))`)])]},{supportsNegative:!0}),t("space-x-reverse",[()=>F([S("--tw-space-x-reverse","0")]),()=>G(":where(& > :not(:last-child))",[n("--tw-sort","row-gap"),n("--tw-space-x-reverse","1")])]),t("space-y-reverse",[()=>F([S("--tw-space-y-reverse","0")]),()=>G(":where(& > :not(:last-child))",[n("--tw-sort","column-gap"),n("--tw-space-y-reverse","1")])]),t("accent-auto",[["accent-color","auto"]]),s("accent",{themeKeys:["--accent-color","--color"],handle:i=>[n("accent-color",i)]}),s("caret",{themeKeys:["--caret-color","--color"],handle:i=>[n("caret-color",i)]}),s("divide",{themeKeys:["--divide-color","--border-color","--color"],handle:i=>[G(":where(& > :not(:last-child))",[n("--tw-sort","divide-color"),n("border-color",i)])]}),t("place-self-auto",[["place-self","auto"]]),t("place-self-start",[["place-self","start"]]),t("place-self-end",[["place-self","end"]]),t("place-self-center",[["place-self","center"]]),t("place-self-end-safe",[["place-self","safe end"]]),t("place-self-center-safe",[["place-self","safe center"]]),t("place-self-stretch",[["place-self","stretch"]]),t("self-auto",[["align-self","auto"]]),t("self-start",[["align-self","flex-start"]]),t("self-end",[["align-self","flex-end"]]),t("self-center",[["align-self","center"]]),t("self-end-safe",[["align-self","safe flex-end"]]),t("self-center-safe",[["align-self","safe center"]]),t("self-stretch",[["align-self","stretch"]]),t("self-baseline",[["align-self","baseline"]]),t("self-baseline-last",[["align-self","last baseline"]]),t("justify-self-auto",[["justify-self","auto"]]),t("justify-self-start",[["justify-self","flex-start"]]),t("justify-self-end",[["justify-self","flex-end"]]),t("justify-self-center",[["justify-self","center"]]),t("justify-self-end-safe",[["justify-self","safe flex-end"]]),t("justify-self-center-safe",[["justify-self","safe center"]]),t("justify-self-stretch",[["justify-self","stretch"]]);for(let i of["auto","hidden","clip","visible","scroll"])t(`overflow-${i}`,[["overflow",i]]),t(`overflow-x-${i}`,[["overflow-x",i]]),t(`overflow-y-${i}`,[["overflow-y",i]]);for(let i of["auto","contain","none"])t(`overscroll-${i}`,[["overscroll-behavior",i]]),t(`overscroll-x-${i}`,[["overscroll-behavior-x",i]]),t(`overscroll-y-${i}`,[["overscroll-behavior-y",i]]);t("scroll-auto",[["scroll-behavior","auto"]]),t("scroll-smooth",[["scroll-behavior","smooth"]]),t("scrollbar-auto",[["scrollbar-width","auto"]]),t("scrollbar-thin",[["scrollbar-width","thin"]]),t("scrollbar-none",[["scrollbar-width","none"]]);{let i=()=>F([S("--tw-scrollbar-thumb","#0000",""),S("--tw-scrollbar-track","#0000","")]);s("scrollbar-thumb",{themeKeys:["--color"],handle:d=>[i(),n("--tw-scrollbar-thumb",d),n("scrollbar-color","var(--tw-scrollbar-thumb) var(--tw-scrollbar-track)")]}),s("scrollbar-track",{themeKeys:["--color"],handle:d=>[i(),n("--tw-scrollbar-track",d),n("scrollbar-color","var(--tw-scrollbar-thumb) var(--tw-scrollbar-track)")]})}t("scrollbar-gutter-auto",[["scrollbar-gutter","auto"]]),t("scrollbar-gutter-stable",[["scrollbar-gutter","stable"]]),t("scrollbar-gutter-both",[["scrollbar-gutter","stable both-edges"]]),t("truncate",[["overflow","hidden"],["text-overflow","ellipsis"],["white-space","nowrap"]]),t("text-ellipsis",[["text-overflow","ellipsis"]]),t("text-clip",[["text-overflow","clip"]]),t("hyphens-none",[["-webkit-hyphens","none"],["hyphens","none"]]),t("hyphens-manual",[["-webkit-hyphens","manual"],["hyphens","manual"]]),t("hyphens-auto",[["-webkit-hyphens","auto"],["hyphens","auto"]]),t("whitespace-normal",[["white-space","normal"]]),t("whitespace-nowrap",[["white-space","nowrap"]]),t("whitespace-pre",[["white-space","pre"]]),t("whitespace-pre-line",[["white-space","pre-line"]]),t("whitespace-pre-wrap",[["white-space","pre-wrap"]]),t("whitespace-break-spaces",[["white-space","break-spaces"]]),l("tab",{handleBareValue:({value:i})=>R(i)?i:null,handle:i=>[n("tab-size",i)]}),o("tab",()=>[{values:["2","4","8"]}]),t("text-wrap",[["text-wrap","wrap"]]),t("text-nowrap",[["text-wrap","nowrap"]]),t("text-balance",[["text-wrap","balance"]]),t("text-pretty",[["text-wrap","pretty"]]),t("break-normal",[["overflow-wrap","normal"],["word-break","normal"]]),t("break-all",[["word-break","break-all"]]),t("break-keep",[["word-break","keep-all"]]),t("wrap-anywhere",[["overflow-wrap","anywhere"]]),t("wrap-break-word",[["overflow-wrap","break-word"]]),t("wrap-normal",[["overflow-wrap","normal"]]);for(let[i,d]of[["rounded",["border-radius"]],["rounded-s",["border-start-start-radius","border-end-start-radius"]],["rounded-e",["border-start-end-radius","border-end-end-radius"]],["rounded-t",["border-top-left-radius","border-top-right-radius"]],["rounded-r",["border-top-right-radius","border-bottom-right-radius"]],["rounded-b",["border-bottom-right-radius","border-bottom-left-radius"]],["rounded-l",["border-top-left-radius","border-bottom-left-radius"]],["rounded-ss",["border-start-start-radius"]],["rounded-se",["border-start-end-radius"]],["rounded-ee",["border-end-end-radius"]],["rounded-es",["border-end-start-radius"]],["rounded-tl",["border-top-left-radius"]],["rounded-tr",["border-top-right-radius"]],["rounded-br",["border-bottom-right-radius"]],["rounded-bl",["border-bottom-left-radius"]]])l(i,{themeKeys:["--radius"],handle:k=>d.map(C=>n(C,k)),staticValues:{none:d.map(k=>n(k,"0")),full:d.map(k=>n(k,"calc(infinity * 1px)"))}});t("border-solid",[["--tw-border-style","solid"],["border-style","solid"]]),t("border-dashed",[["--tw-border-style","dashed"],["border-style","dashed"]]),t("border-dotted",[["--tw-border-style","dotted"],["border-style","dotted"]]),t("border-double",[["--tw-border-style","double"],["border-style","double"]]),t("border-hidden",[["--tw-border-style","hidden"],["border-style","hidden"]]),t("border-none",[["--tw-border-style","none"],["border-style","none"]]);{let d=function(k,C){r.functional(k,y=>{if(!y.value){if(y.modifier)return;let N=e.get(["--default-border-width"])??"1px",_=C.width(N);return _?[i(),..._]:void 0}if(y.value.kind==="arbitrary"){let N=y.value.value;switch(y.value.dataType??Q(N,["color","line-width","length"])){case"line-width":case"length":{if(y.modifier)return;let $=C.width(N);return $?[i(),...$]:void 0}default:return N=ee(N,y.modifier,e),N===null?void 0:C.color(N)}}{let N=oe(y,e,["--border-color","--color"]);if(N)return C.color(N)}{if(y.modifier)return;let N=e.resolve(y.value.value,["--border-width"]);if(N){let _=C.width(N);return _?[i(),..._]:void 0}if(R(y.value.value)){let _=C.width(`${y.value.value}px`);return _?[i(),..._]:void 0}}}),o(k,()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--border-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(y,N)=>`${N*5}`),hasDefaultValue:!0},{values:["0","2","4","8"],valueThemeKeys:["--border-width"]}])};var P=d;let i=()=>F([S("--tw-border-style","solid")]);d("border",{width:k=>[n("border-style","var(--tw-border-style)"),n("border-width",k)],color:k=>[n("border-color",k)]}),d("border-x",{width:k=>[n("border-inline-style","var(--tw-border-style)"),n("border-inline-width",k)],color:k=>[n("border-inline-color",k)]}),d("border-y",{width:k=>[n("border-block-style","var(--tw-border-style)"),n("border-block-width",k)],color:k=>[n("border-block-color",k)]}),d("border-s",{width:k=>[n("border-inline-start-style","var(--tw-border-style)"),n("border-inline-start-width",k)],color:k=>[n("border-inline-start-color",k)]}),d("border-e",{width:k=>[n("border-inline-end-style","var(--tw-border-style)"),n("border-inline-end-width",k)],color:k=>[n("border-inline-end-color",k)]}),d("border-bs",{width:k=>[n("border-block-start-style","var(--tw-border-style)"),n("border-block-start-width",k)],color:k=>[n("border-block-start-color",k)]}),d("border-be",{width:k=>[n("border-block-end-style","var(--tw-border-style)"),n("border-block-end-width",k)],color:k=>[n("border-block-end-color",k)]}),d("border-t",{width:k=>[n("border-top-style","var(--tw-border-style)"),n("border-top-width",k)],color:k=>[n("border-top-color",k)]}),d("border-r",{width:k=>[n("border-right-style","var(--tw-border-style)"),n("border-right-width",k)],color:k=>[n("border-right-color",k)]}),d("border-b",{width:k=>[n("border-bottom-style","var(--tw-border-style)"),n("border-bottom-width",k)],color:k=>[n("border-bottom-color",k)]}),d("border-l",{width:k=>[n("border-left-style","var(--tw-border-style)"),n("border-left-width",k)],color:k=>[n("border-left-color",k)]}),l("divide-x",{defaultValue:e.get(["--default-border-width"])??"1px",themeKeys:["--divide-width","--border-width"],handleBareValue:({value:k})=>R(k)?`${k}px`:null,handle:k=>[F([S("--tw-divide-x-reverse","0")]),G(":where(& > :not(:last-child))",[n("--tw-sort","divide-x-width"),i(),n("--tw-divide-x-reverse","0"),n("border-inline-style","var(--tw-border-style)"),n("border-inline-start-width",`calc(${k} * var(--tw-divide-x-reverse))`),n("border-inline-end-width",`calc(${k} * calc(1 - var(--tw-divide-x-reverse)))`)])]}),l("divide-y",{defaultValue:e.get(["--default-border-width"])??"1px",themeKeys:["--divide-width","--border-width"],handleBareValue:({value:k})=>R(k)?`${k}px`:null,handle:k=>[F([S("--tw-divide-y-reverse","0")]),G(":where(& > :not(:last-child))",[n("--tw-sort","divide-y-width"),i(),n("--tw-divide-y-reverse","0"),n("border-bottom-style","var(--tw-border-style)"),n("border-top-style","var(--tw-border-style)"),n("border-top-width",`calc(${k} * var(--tw-divide-y-reverse))`),n("border-bottom-width",`calc(${k} * calc(1 - var(--tw-divide-y-reverse)))`)])]}),o("divide-x",()=>[{values:["0","2","4","8"],valueThemeKeys:["--divide-width","--border-width"],hasDefaultValue:!0}]),o("divide-y",()=>[{values:["0","2","4","8"],valueThemeKeys:["--divide-width","--border-width"],hasDefaultValue:!0}]),t("divide-x-reverse",[()=>F([S("--tw-divide-x-reverse","0")]),()=>G(":where(& > :not(:last-child))",[n("--tw-divide-x-reverse","1")])]),t("divide-y-reverse",[()=>F([S("--tw-divide-y-reverse","0")]),()=>G(":where(& > :not(:last-child))",[n("--tw-divide-y-reverse","1")])]);for(let k of["solid","dashed","dotted","double","none"])t(`divide-${k}`,[()=>G(":where(& > :not(:last-child))",[n("--tw-sort","divide-style"),n("--tw-border-style",k),n("border-style",k)])])}t("bg-auto",[["background-size","auto"]]),t("bg-cover",[["background-size","cover"]]),t("bg-contain",[["background-size","contain"]]),l("bg-size",{handle(i){if(i)return[n("background-size",i)]}}),t("bg-fixed",[["background-attachment","fixed"]]),t("bg-local",[["background-attachment","local"]]),t("bg-scroll",[["background-attachment","scroll"]]),t("bg-top",[["background-position","top"]]),t("bg-top-left",[["background-position","left top"]]),t("bg-top-right",[["background-position","right top"]]),t("bg-bottom",[["background-position","bottom"]]),t("bg-bottom-left",[["background-position","left bottom"]]),t("bg-bottom-right",[["background-position","right bottom"]]),t("bg-left",[["background-position","left"]]),t("bg-right",[["background-position","right"]]),t("bg-center",[["background-position","center"]]),l("bg-position",{handle(i){if(i)return[n("background-position",i)]}}),t("bg-repeat",[["background-repeat","repeat"]]),t("bg-no-repeat",[["background-repeat","no-repeat"]]),t("bg-repeat-x",[["background-repeat","repeat-x"]]),t("bg-repeat-y",[["background-repeat","repeat-y"]]),t("bg-repeat-round",[["background-repeat","round"]]),t("bg-repeat-space",[["background-repeat","space"]]),t("bg-none",[["background-image","none"]]);{let k=function(N){let _="in oklab";if(N?.kind==="named")switch(N.value){case"longer":case"shorter":case"increasing":case"decreasing":_=`in oklch ${N.value} hue`;break;default:_=`in ${N.value}`}else N?.kind==="arbitrary"&&(_=N.value);return _},C=function({negative:N}){return _=>{if(!_.value)return;if(_.value.kind==="arbitrary"){if(_.modifier)return;let j=_.value.value;return(_.value.dataType??Q(j,["angle"]))==="angle"?(j=N?`calc(${j} * -1)`:`${j}`,[n("--tw-gradient-position",j),n("background-image",`linear-gradient(var(--tw-gradient-stops,${j}))`)]):N?void 0:[n("--tw-gradient-position",j),n("background-image",`linear-gradient(var(--tw-gradient-stops,${j}))`)]}let $=_.value.value;if(!N&&d.has($))$=d.get($);else if(R($))$=N?`calc(${$}deg * -1)`:`${$}deg`;else return;let E=k(_.modifier);return[n("--tw-gradient-position",`${$}`),Y("@supports (background-image: linear-gradient(in lab, red, red))",[n("--tw-gradient-position",`${$} ${E}`)]),n("background-image","linear-gradient(var(--tw-gradient-stops))")]}},y=function({negative:N}){return _=>{if(_.value?.kind==="arbitrary"){if(_.modifier)return;let j=_.value.value;return[n("--tw-gradient-position",j),n("background-image",`conic-gradient(var(--tw-gradient-stops,${j}))`)]}let $=k(_.modifier);if(!_.value)return[n("--tw-gradient-position",$),n("background-image","conic-gradient(var(--tw-gradient-stops))")];let E=_.value.value;if(R(E))return E=N?`calc(${E}deg * -1)`:`${E}deg`,[n("--tw-gradient-position",`from ${E} ${$}`),n("background-image","conic-gradient(var(--tw-gradient-stops))")]}};var D=k,B=C,W=y;let i=["oklab","oklch","srgb","hsl","longer","shorter","increasing","decreasing"],d=new Map([["to-t","to top"],["to-tr","to top right"],["to-r","to right"],["to-br","to bottom right"],["to-b","to bottom"],["to-bl","to bottom left"],["to-l","to left"],["to-tl","to top left"]]);r.functional("-bg-linear",C({negative:!0})),r.functional("bg-linear",C({negative:!1})),o("bg-linear",()=>[{values:[...d.keys()],modifiers:i},{values:["0","30","60","90","120","150","180","210","240","270","300","330"],supportsNegative:!0,modifiers:i}]),r.functional("-bg-conic",y({negative:!0})),r.functional("bg-conic",y({negative:!1})),o("bg-conic",()=>[{hasDefaultValue:!0,modifiers:i},{values:["0","30","60","90","120","150","180","210","240","270","300","330"],supportsNegative:!0,modifiers:i}]),r.functional("bg-radial",N=>{if(!N.value){let _=k(N.modifier);return[n("--tw-gradient-position",_),n("background-image","radial-gradient(var(--tw-gradient-stops))")]}if(N.value.kind==="arbitrary"){if(N.modifier)return;let _=N.value.value;return[n("--tw-gradient-position",_),n("background-image",`radial-gradient(var(--tw-gradient-stops,${_}))`)]}}),o("bg-radial",()=>[{hasDefaultValue:!0,modifiers:i}])}r.functional("bg",i=>{if(i.value){if(i.value.kind==="arbitrary"){let d=i.value.value;switch(i.value.dataType??Q(d,["image","color","percentage","position","bg-size","length","url"])){case"percentage":case"position":return i.modifier?void 0:[n("background-position",d)];case"bg-size":case"length":case"size":return i.modifier?void 0:[n("background-size",d)];case"image":case"url":return i.modifier?void 0:[n("background-image",d)];default:return d=ee(d,i.modifier,e),d===null?void 0:[n("background-color",d)]}}{let d=oe(i,e,["--background-color","--color"]);if(d)return[n("background-color",d)]}{if(i.modifier)return;let d=e.resolve(i.value.value,["--background-image"]);if(d)return[n("background-image",d)]}}}),o("bg",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--background-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(i,d)=>`${d*5}`)},{values:[],valueThemeKeys:["--background-image"]}]);let g=()=>F([S("--tw-gradient-position"),S("--tw-gradient-from","#0000",""),S("--tw-gradient-via","#0000",""),S("--tw-gradient-to","#0000",""),S("--tw-gradient-stops"),S("--tw-gradient-via-stops"),S("--tw-gradient-from-position","0%",""),S("--tw-gradient-via-position","50%",""),S("--tw-gradient-to-position","100%","")]);function b(i,d){r.functional(i,k=>{if(k.value){if(k.value.kind==="arbitrary"){let C=k.value.value;switch(k.value.dataType??Q(C,["color","length","percentage"])){case"length":case"percentage":return k.modifier?void 0:d.position(C);default:return C=ee(C,k.modifier,e),C===null?void 0:d.color(C)}}{let C=oe(k,e,["--background-color","--color"]);if(C)return d.color(C)}{if(k.modifier)return;let C=e.resolve(k.value.value,["--gradient-color-stop-positions"]);if(C)return d.position(C);if(k.value.value[k.value.value.length-1]==="%"&&R(k.value.value.slice(0,-1)))return d.position(k.value.value)}}}),o(i,()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--background-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(k,C)=>`${C*5}`)},{values:Array.from({length:21},(k,C)=>`${C*5}%`),valueThemeKeys:["--gradient-color-stop-positions"]}])}b("from",{color:i=>[g(),n("--tw-sort","--tw-gradient-from"),n("--tw-gradient-from",i),n("--tw-gradient-stops","var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))")],position:i=>[g(),n("--tw-gradient-from-position",i)]}),t("via-none",[["--tw-gradient-via-stops","initial"]]),b("via",{color:i=>[g(),n("--tw-sort","--tw-gradient-via"),n("--tw-gradient-via",i),n("--tw-gradient-via-stops","var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"),n("--tw-gradient-stops","var(--tw-gradient-via-stops)")],position:i=>[g(),n("--tw-gradient-via-position",i)]}),b("to",{color:i=>[g(),n("--tw-sort","--tw-gradient-to"),n("--tw-gradient-to",i),n("--tw-gradient-stops","var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))")],position:i=>[g(),n("--tw-gradient-to-position",i)]}),t("mask-none",[["mask-image","none"]]),r.functional("mask",i=>{if(!i.value||i.modifier||i.value.kind!=="arbitrary")return;let d=i.value.value;switch(i.value.dataType??Q(d,["image","percentage","position","bg-size","length","url"])){case"percentage":case"position":return i.modifier?void 0:[n("mask-position",d)];case"bg-size":case"length":case"size":return[n("mask-size",d)];default:return[n("mask-image",d)]}}),t("mask-add",[["mask-composite","add"]]),t("mask-subtract",[["mask-composite","subtract"]]),t("mask-intersect",[["mask-composite","intersect"]]),t("mask-exclude",[["mask-composite","exclude"]]),t("mask-alpha",[["mask-mode","alpha"]]),t("mask-luminance",[["mask-mode","luminance"]]),t("mask-match",[["mask-mode","match-source"]]),t("mask-type-alpha",[["mask-type","alpha"]]),t("mask-type-luminance",[["mask-type","luminance"]]),t("mask-auto",[["mask-size","auto"]]),t("mask-cover",[["mask-size","cover"]]),t("mask-contain",[["mask-size","contain"]]),l("mask-size",{handle(i){if(i)return[n("mask-size",i)]}}),t("mask-top",[["mask-position","top"]]),t("mask-top-left",[["mask-position","left top"]]),t("mask-top-right",[["mask-position","right top"]]),t("mask-bottom",[["mask-position","bottom"]]),t("mask-bottom-left",[["mask-position","left bottom"]]),t("mask-bottom-right",[["mask-position","right bottom"]]),t("mask-left",[["mask-position","left"]]),t("mask-right",[["mask-position","right"]]),t("mask-center",[["mask-position","center"]]),l("mask-position",{handle(i){if(i)return[n("mask-position",i)]}}),t("mask-repeat",[["mask-repeat","repeat"]]),t("mask-no-repeat",[["mask-repeat","no-repeat"]]),t("mask-repeat-x",[["mask-repeat","repeat-x"]]),t("mask-repeat-y",[["mask-repeat","repeat-y"]]),t("mask-repeat-round",[["mask-repeat","round"]]),t("mask-repeat-space",[["mask-repeat","space"]]),t("mask-clip-border",[["mask-clip","border-box"]]),t("mask-clip-padding",[["mask-clip","padding-box"]]),t("mask-clip-content",[["mask-clip","content-box"]]),t("mask-clip-fill",[["mask-clip","fill-box"]]),t("mask-clip-stroke",[["mask-clip","stroke-box"]]),t("mask-clip-view",[["mask-clip","view-box"]]),t("mask-no-clip",[["mask-clip","no-clip"]]),t("mask-origin-border",[["mask-origin","border-box"]]),t("mask-origin-padding",[["mask-origin","padding-box"]]),t("mask-origin-content",[["mask-origin","content-box"]]),t("mask-origin-fill",[["mask-origin","fill-box"]]),t("mask-origin-stroke",[["mask-origin","stroke-box"]]),t("mask-origin-view",[["mask-origin","view-box"]]);let x=()=>F([S("--tw-mask-linear","linear-gradient(#fff, #fff)"),S("--tw-mask-radial","linear-gradient(#fff, #fff)"),S("--tw-mask-conic","linear-gradient(#fff, #fff)")]);function T(i,d){r.functional(i,k=>{if(k.value){if(k.value.kind==="arbitrary"){let C=k.value.value;switch(k.value.dataType??Q(C,["length","percentage","color"])){case"color":return C=ee(C,k.modifier,e),C===null?void 0:d.color(C);case"percentage":return k.modifier||!R(C.slice(0,-1))?void 0:d.position(C);default:return k.modifier?void 0:d.position(C)}}{let C=oe(k,e,["--background-color","--color"]);if(C)return d.color(C)}{if(k.modifier)return;let C=Q(k.value.value,["number","percentage"]);if(!C)return;switch(C){case"number":return!e.resolve(null,["--spacing"])||!le(k.value.value)?void 0:d.position(`--spacing(${k.value.value})`);case"percentage":return R(k.value.value.slice(0,-1))?d.position(k.value.value):void 0;default:return}}}}),o(i,()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--background-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(k,C)=>`${C*5}`)},{values:Array.from({length:21},(k,C)=>`${C*5}%`),valueThemeKeys:["--gradient-color-stop-positions"]}]),o(i,()=>[{values:Array.from({length:21},(k,C)=>`${C*5}%`)},{values:e.get(["--spacing"])?gt:[]},{values:["current","inherit","transparent"],valueThemeKeys:["--background-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(k,C)=>`${C*5}`)}])}let A=()=>F([S("--tw-mask-left","linear-gradient(#fff, #fff)"),S("--tw-mask-right","linear-gradient(#fff, #fff)"),S("--tw-mask-bottom","linear-gradient(#fff, #fff)"),S("--tw-mask-top","linear-gradient(#fff, #fff)")]);function w(i,d,k){T(i,{color(C){let y=[x(),A(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-linear","var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top)")];for(let N of["top","right","bottom","left"])k[N]&&(y.push(n(`--tw-mask-${N}`,`linear-gradient(to ${N}, var(--tw-mask-${N}-from-color) var(--tw-mask-${N}-from-position), var(--tw-mask-${N}-to-color) var(--tw-mask-${N}-to-position))`)),y.push(F([S(`--tw-mask-${N}-from-position`,"0%"),S(`--tw-mask-${N}-to-position`,"100%"),S(`--tw-mask-${N}-from-color`,"black"),S(`--tw-mask-${N}-to-color`,"transparent")])),y.push(n(`--tw-mask-${N}-${d}-color`,C)));return y},position(C){let y=[x(),A(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-linear","var(--tw-mask-left), var(--tw-mask-right), var(--tw-mask-bottom), var(--tw-mask-top)")];for(let N of["top","right","bottom","left"])k[N]&&(y.push(n(`--tw-mask-${N}`,`linear-gradient(to ${N}, var(--tw-mask-${N}-from-color) var(--tw-mask-${N}-from-position), var(--tw-mask-${N}-to-color) var(--tw-mask-${N}-to-position))`)),y.push(F([S(`--tw-mask-${N}-from-position`,"0%"),S(`--tw-mask-${N}-to-position`,"100%"),S(`--tw-mask-${N}-from-color`,"black"),S(`--tw-mask-${N}-to-color`,"transparent")])),y.push(n(`--tw-mask-${N}-${d}-position`,C)));return y}})}w("mask-x-from","from",{top:!1,right:!0,bottom:!1,left:!0}),w("mask-x-to","to",{top:!1,right:!0,bottom:!1,left:!0}),w("mask-y-from","from",{top:!0,right:!1,bottom:!0,left:!1}),w("mask-y-to","to",{top:!0,right:!1,bottom:!0,left:!1}),w("mask-t-from","from",{top:!0,right:!1,bottom:!1,left:!1}),w("mask-t-to","to",{top:!0,right:!1,bottom:!1,left:!1}),w("mask-r-from","from",{top:!1,right:!0,bottom:!1,left:!1}),w("mask-r-to","to",{top:!1,right:!0,bottom:!1,left:!1}),w("mask-b-from","from",{top:!1,right:!1,bottom:!0,left:!1}),w("mask-b-to","to",{top:!1,right:!1,bottom:!0,left:!1}),w("mask-l-from","from",{top:!1,right:!1,bottom:!1,left:!0}),w("mask-l-to","to",{top:!1,right:!1,bottom:!1,left:!0});let K=()=>F([S("--tw-mask-linear-position","0deg"),S("--tw-mask-linear-from-position","0%"),S("--tw-mask-linear-to-position","100%"),S("--tw-mask-linear-from-color","black"),S("--tw-mask-linear-to-color","transparent")]);l("mask-linear",{defaultValue:null,supportsNegative:!0,supportsFractions:!1,handleBareValue({value:i}){if(!R(i))return null;let d=Number(i);return d===0?"0deg":d===1?"1deg":`calc(1deg * ${i})`},handleNegativeBareValue({value:i}){if(!R(i))return null;let d=Number(i);return d===0?"0deg":d===1?"-1deg":`calc(1deg * -${i})`},handle:i=>[x(),K(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-linear","linear-gradient(var(--tw-mask-linear-stops, var(--tw-mask-linear-position)))"),n("--tw-mask-linear-position",i)]}),o("mask-linear",()=>[{supportsNegative:!0,values:["0","1","2","3","6","12","45","90","180"]}]),T("mask-linear-from",{color:i=>[x(),K(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-linear-stops","var(--tw-mask-linear-position), var(--tw-mask-linear-from-color) var(--tw-mask-linear-from-position), var(--tw-mask-linear-to-color) var(--tw-mask-linear-to-position)"),n("--tw-mask-linear","linear-gradient(var(--tw-mask-linear-stops))"),n("--tw-mask-linear-from-color",i)],position:i=>[x(),K(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-linear-stops","var(--tw-mask-linear-position), var(--tw-mask-linear-from-color) var(--tw-mask-linear-from-position), var(--tw-mask-linear-to-color) var(--tw-mask-linear-to-position)"),n("--tw-mask-linear","linear-gradient(var(--tw-mask-linear-stops))"),n("--tw-mask-linear-from-position",i)]}),T("mask-linear-to",{color:i=>[x(),K(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-linear-stops","var(--tw-mask-linear-position), var(--tw-mask-linear-from-color) var(--tw-mask-linear-from-position), var(--tw-mask-linear-to-color) var(--tw-mask-linear-to-position)"),n("--tw-mask-linear","linear-gradient(var(--tw-mask-linear-stops))"),n("--tw-mask-linear-to-color",i)],position:i=>[x(),K(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-linear-stops","var(--tw-mask-linear-position), var(--tw-mask-linear-from-color) var(--tw-mask-linear-from-position), var(--tw-mask-linear-to-color) var(--tw-mask-linear-to-position)"),n("--tw-mask-linear","linear-gradient(var(--tw-mask-linear-stops))"),n("--tw-mask-linear-to-position",i)]});let L=()=>F([S("--tw-mask-radial-from-position","0%"),S("--tw-mask-radial-to-position","100%"),S("--tw-mask-radial-from-color","black"),S("--tw-mask-radial-to-color","transparent"),S("--tw-mask-radial-shape","ellipse"),S("--tw-mask-radial-size","farthest-corner"),S("--tw-mask-radial-position","center")]);t("mask-circle",[["--tw-mask-radial-shape","circle"]]),t("mask-ellipse",[["--tw-mask-radial-shape","ellipse"]]),t("mask-radial-closest-side",[["--tw-mask-radial-size","closest-side"]]),t("mask-radial-farthest-side",[["--tw-mask-radial-size","farthest-side"]]),t("mask-radial-closest-corner",[["--tw-mask-radial-size","closest-corner"]]),t("mask-radial-farthest-corner",[["--tw-mask-radial-size","farthest-corner"]]),t("mask-radial-at-top",[["--tw-mask-radial-position","top"]]),t("mask-radial-at-top-left",[["--tw-mask-radial-position","top left"]]),t("mask-radial-at-top-right",[["--tw-mask-radial-position","top right"]]),t("mask-radial-at-bottom",[["--tw-mask-radial-position","bottom"]]),t("mask-radial-at-bottom-left",[["--tw-mask-radial-position","bottom left"]]),t("mask-radial-at-bottom-right",[["--tw-mask-radial-position","bottom right"]]),t("mask-radial-at-left",[["--tw-mask-radial-position","left"]]),t("mask-radial-at-right",[["--tw-mask-radial-position","right"]]),t("mask-radial-at-center",[["--tw-mask-radial-position","center"]]),l("mask-radial-at",{defaultValue:null,supportsNegative:!1,supportsFractions:!1,handle:i=>[n("--tw-mask-radial-position",i)]}),l("mask-radial",{defaultValue:null,supportsNegative:!1,supportsFractions:!1,handle:i=>[x(),L(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-radial","radial-gradient(var(--tw-mask-radial-stops, var(--tw-mask-radial-size)))"),n("--tw-mask-radial-size",i)]}),T("mask-radial-from",{color:i=>[x(),L(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-radial-stops","var(--tw-mask-radial-shape) var(--tw-mask-radial-size) at var(--tw-mask-radial-position), var(--tw-mask-radial-from-color) var(--tw-mask-radial-from-position), var(--tw-mask-radial-to-color) var(--tw-mask-radial-to-position)"),n("--tw-mask-radial","radial-gradient(var(--tw-mask-radial-stops))"),n("--tw-mask-radial-from-color",i)],position:i=>[x(),L(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-radial-stops","var(--tw-mask-radial-shape) var(--tw-mask-radial-size) at var(--tw-mask-radial-position), var(--tw-mask-radial-from-color) var(--tw-mask-radial-from-position), var(--tw-mask-radial-to-color) var(--tw-mask-radial-to-position)"),n("--tw-mask-radial","radial-gradient(var(--tw-mask-radial-stops))"),n("--tw-mask-radial-from-position",i)]}),T("mask-radial-to",{color:i=>[x(),L(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-radial-stops","var(--tw-mask-radial-shape) var(--tw-mask-radial-size) at var(--tw-mask-radial-position), var(--tw-mask-radial-from-color) var(--tw-mask-radial-from-position), var(--tw-mask-radial-to-color) var(--tw-mask-radial-to-position)"),n("--tw-mask-radial","radial-gradient(var(--tw-mask-radial-stops))"),n("--tw-mask-radial-to-color",i)],position:i=>[x(),L(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-radial-stops","var(--tw-mask-radial-shape) var(--tw-mask-radial-size) at var(--tw-mask-radial-position), var(--tw-mask-radial-from-color) var(--tw-mask-radial-from-position), var(--tw-mask-radial-to-color) var(--tw-mask-radial-to-position)"),n("--tw-mask-radial","radial-gradient(var(--tw-mask-radial-stops))"),n("--tw-mask-radial-to-position",i)]});let z=()=>F([S("--tw-mask-conic-position","0deg"),S("--tw-mask-conic-from-position","0%"),S("--tw-mask-conic-to-position","100%"),S("--tw-mask-conic-from-color","black"),S("--tw-mask-conic-to-color","transparent")]);l("mask-conic",{defaultValue:null,supportsNegative:!0,supportsFractions:!1,handleBareValue({value:i}){if(!R(i))return null;let d=Number(i);return d===0?"0deg":d===1?"1deg":`calc(1deg * ${i})`},handleNegativeBareValue({value:i}){if(!R(i))return null;let d=Number(i);return d===0?"0deg":d===1?"-1deg":`calc(1deg * -${i})`},handle:i=>[x(),z(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-conic","conic-gradient(var(--tw-mask-conic-stops, var(--tw-mask-conic-position)))"),n("--tw-mask-conic-position",i)]}),o("mask-conic",()=>[{supportsNegative:!0,values:["0","1","2","3","6","12","45","90","180"]}]),T("mask-conic-from",{color:i=>[x(),z(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-conic-stops","from var(--tw-mask-conic-position), var(--tw-mask-conic-from-color) var(--tw-mask-conic-from-position), var(--tw-mask-conic-to-color) var(--tw-mask-conic-to-position)"),n("--tw-mask-conic","conic-gradient(var(--tw-mask-conic-stops))"),n("--tw-mask-conic-from-color",i)],position:i=>[x(),z(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-conic-stops","from var(--tw-mask-conic-position), var(--tw-mask-conic-from-color) var(--tw-mask-conic-from-position), var(--tw-mask-conic-to-color) var(--tw-mask-conic-to-position)"),n("--tw-mask-conic","conic-gradient(var(--tw-mask-conic-stops))"),n("--tw-mask-conic-from-position",i)]}),T("mask-conic-to",{color:i=>[x(),z(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-conic-stops","from var(--tw-mask-conic-position), var(--tw-mask-conic-from-color) var(--tw-mask-conic-from-position), var(--tw-mask-conic-to-color) var(--tw-mask-conic-to-position)"),n("--tw-mask-conic","conic-gradient(var(--tw-mask-conic-stops))"),n("--tw-mask-conic-to-color",i)],position:i=>[x(),z(),n("mask-image","var(--tw-mask-linear), var(--tw-mask-radial), var(--tw-mask-conic)"),n("mask-composite","intersect"),n("--tw-mask-conic-stops","from var(--tw-mask-conic-position), var(--tw-mask-conic-from-color) var(--tw-mask-conic-from-position), var(--tw-mask-conic-to-color) var(--tw-mask-conic-to-position)"),n("--tw-mask-conic","conic-gradient(var(--tw-mask-conic-stops))"),n("--tw-mask-conic-to-position",i)]}),t("box-decoration-slice",[["-webkit-box-decoration-break","slice"],["box-decoration-break","slice"]]),t("box-decoration-clone",[["-webkit-box-decoration-break","clone"],["box-decoration-break","clone"]]),t("bg-clip-text",[["background-clip","text"]]),t("bg-clip-border",[["background-clip","border-box"]]),t("bg-clip-padding",[["background-clip","padding-box"]]),t("bg-clip-content",[["background-clip","content-box"]]),t("bg-origin-border",[["background-origin","border-box"]]),t("bg-origin-padding",[["background-origin","padding-box"]]),t("bg-origin-content",[["background-origin","content-box"]]);for(let i of["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"])t(`bg-blend-${i}`,[["background-blend-mode",i]]),t(`mix-blend-${i}`,[["mix-blend-mode",i]]);t("mix-blend-plus-darker",[["mix-blend-mode","plus-darker"]]),t("mix-blend-plus-lighter",[["mix-blend-mode","plus-lighter"]]),t("fill-none",[["fill","none"]]),r.functional("fill",i=>{if(!i.value)return;if(i.value.kind==="arbitrary"){let k=ee(i.value.value,i.modifier,e);return k===null?void 0:[n("fill",k)]}let d=oe(i,e,["--fill","--color"]);if(d)return[n("fill",d)]}),o("fill",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--fill","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(i,d)=>`${d*5}`)}]),t("stroke-none",[["stroke","none"]]),r.functional("stroke",i=>{if(i.value){if(i.value.kind==="arbitrary"){let d=i.value.value;switch(i.value.dataType??Q(d,["color","number","length","percentage"])){case"number":case"length":case"percentage":return i.modifier?void 0:[n("stroke-width",d)];default:return d=ee(i.value.value,i.modifier,e),d===null?void 0:[n("stroke",d)]}}{let d=oe(i,e,["--stroke","--color"]);if(d)return[n("stroke",d)]}{let d=e.resolve(i.value.value,["--stroke-width"]);if(d)return[n("stroke-width",d)];if(R(i.value.value))return[n("stroke-width",i.value.value)]}}}),o("stroke",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--stroke","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(i,d)=>`${d*5}`)},{values:["0","1","2","3"],valueThemeKeys:["--stroke-width"]}]),t("object-contain",[["object-fit","contain"]]),t("object-cover",[["object-fit","cover"]]),t("object-fill",[["object-fit","fill"]]),t("object-none",[["object-fit","none"]]),t("object-scale-down",[["object-fit","scale-down"]]),l("object",{themeKeys:["--object-position"],handle:i=>[n("object-position",i)],staticValues:{top:[n("object-position","top")],"top-left":[n("object-position","left top")],"top-right":[n("object-position","right top")],bottom:[n("object-position","bottom")],"bottom-left":[n("object-position","left bottom")],"bottom-right":[n("object-position","right bottom")],left:[n("object-position","left")],right:[n("object-position","right")],center:[n("object-position","center")]}});for(let[i,d]of[["p","padding"],["px","padding-inline"],["py","padding-block"],["ps","padding-inline-start"],["pe","padding-inline-end"],["pbs","padding-block-start"],["pbe","padding-block-end"],["pt","padding-top"],["pr","padding-right"],["pb","padding-bottom"],["pl","padding-left"]])a(i,["--padding","--spacing"],k=>[n(d,k)]);t("text-left",[["text-align","left"]]),t("text-center",[["text-align","center"]]),t("text-right",[["text-align","right"]]),t("text-justify",[["text-align","justify"]]),t("text-start",[["text-align","start"]]),t("text-end",[["text-align","end"]]),a("indent",["--text-indent","--spacing"],i=>[n("text-indent",i)],{supportsNegative:!0}),t("align-baseline",[["vertical-align","baseline"]]),t("align-top",[["vertical-align","top"]]),t("align-middle",[["vertical-align","middle"]]),t("align-bottom",[["vertical-align","bottom"]]),t("align-text-top",[["vertical-align","text-top"]]),t("align-text-bottom",[["vertical-align","text-bottom"]]),t("align-sub",[["vertical-align","sub"]]),t("align-super",[["vertical-align","super"]]),l("align",{themeKeys:[],handle:i=>[n("vertical-align",i)]}),r.functional("font",i=>{if(!(!i.value||i.modifier)){if(i.value.kind==="arbitrary"){let d=i.value.value;switch(i.value.dataType??Q(d,["number","generic-name","family-name"])){case"generic-name":case"family-name":return[n("font-family",d)];default:return[F([S("--tw-font-weight")]),n("--tw-font-weight",d),n("font-weight",d)]}}{let d=e.resolveWith(i.value.value,["--font"],["--font-feature-settings","--font-variation-settings"]);if(d){let[k,C={}]=d;return[n("font-family",k),n("font-feature-settings",C["--font-feature-settings"]),n("font-variation-settings",C["--font-variation-settings"])]}}{let d=e.resolve(i.value.value,["--font-weight"]);if(d)return[F([S("--tw-font-weight")]),n("--tw-font-weight",d),n("font-weight",d)]}}}),o("font",()=>[{values:[],valueThemeKeys:["--font"]},{values:[],valueThemeKeys:["--font-weight"]}]),l("font-features",{themeKeys:[],handle:i=>[n("font-feature-settings",i)]}),t("uppercase",[["text-transform","uppercase"]]),t("lowercase",[["text-transform","lowercase"]]),t("capitalize",[["text-transform","capitalize"]]),t("normal-case",[["text-transform","none"]]),t("italic",[["font-style","italic"]]),t("not-italic",[["font-style","normal"]]),t("underline",[["text-decoration-line","underline"]]),t("overline",[["text-decoration-line","overline"]]),t("line-through",[["text-decoration-line","line-through"]]),t("no-underline",[["text-decoration-line","none"]]),t("font-stretch-normal",[["font-stretch","normal"]]),t("font-stretch-ultra-condensed",[["font-stretch","ultra-condensed"]]),t("font-stretch-extra-condensed",[["font-stretch","extra-condensed"]]),t("font-stretch-condensed",[["font-stretch","condensed"]]),t("font-stretch-semi-condensed",[["font-stretch","semi-condensed"]]),t("font-stretch-semi-expanded",[["font-stretch","semi-expanded"]]),t("font-stretch-expanded",[["font-stretch","expanded"]]),t("font-stretch-extra-expanded",[["font-stretch","extra-expanded"]]),t("font-stretch-ultra-expanded",[["font-stretch","ultra-expanded"]]),l("font-stretch",{handleBareValue:({value:i})=>{if(!i.endsWith("%"))return null;let d=Number(i.slice(0,-1));return!R(d)||Number.isNaN(d)||d<50||d>200?null:i},handle:i=>[n("font-stretch",i)]}),o("font-stretch",()=>[{values:["50%","75%","90%","95%","100%","105%","110%","125%","150%","200%"]}]),s("placeholder",{themeKeys:["--placeholder-color","--color"],handle:i=>[G("&::placeholder",[n("--tw-sort","placeholder-color"),n("color",i)])]}),t("decoration-solid",[["text-decoration-style","solid"]]),t("decoration-double",[["text-decoration-style","double"]]),t("decoration-dotted",[["text-decoration-style","dotted"]]),t("decoration-dashed",[["text-decoration-style","dashed"]]),t("decoration-wavy",[["text-decoration-style","wavy"]]),t("decoration-auto",[["text-decoration-thickness","auto"]]),t("decoration-from-font",[["text-decoration-thickness","from-font"]]),r.functional("decoration",i=>{if(i.value){if(i.value.kind==="arbitrary"){let d=i.value.value;switch(i.value.dataType??Q(d,["color","length","percentage"])){case"length":case"percentage":return i.modifier?void 0:[n("text-decoration-thickness",d)];default:return d=ee(d,i.modifier,e),d===null?void 0:[n("text-decoration-color",d)]}}{let d=e.resolve(i.value.value,["--text-decoration-thickness"]);if(d)return i.modifier?void 0:[n("text-decoration-thickness",d)];if(R(i.value.value))return i.modifier?void 0:[n("text-decoration-thickness",`${i.value.value}px`)]}{let d=oe(i,e,["--text-decoration-color","--color"]);if(d)return[n("text-decoration-color",d)]}}}),o("decoration",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--text-decoration-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(i,d)=>`${d*5}`)},{values:["0","1","2"],valueThemeKeys:["--text-decoration-thickness"]}]),l("animate",{themeKeys:["--animate"],handle:i=>[n("animation",i)],staticValues:{none:[n("animation","none")]}});{let i=["var(--tw-blur,)","var(--tw-brightness,)","var(--tw-contrast,)","var(--tw-grayscale,)","var(--tw-hue-rotate,)","var(--tw-invert,)","var(--tw-saturate,)","var(--tw-sepia,)","var(--tw-drop-shadow,)"].join(" "),d=["var(--tw-backdrop-blur,)","var(--tw-backdrop-brightness,)","var(--tw-backdrop-contrast,)","var(--tw-backdrop-grayscale,)","var(--tw-backdrop-hue-rotate,)","var(--tw-backdrop-invert,)","var(--tw-backdrop-opacity,)","var(--tw-backdrop-saturate,)","var(--tw-backdrop-sepia,)"].join(" "),k=()=>F([S("--tw-blur"),S("--tw-brightness"),S("--tw-contrast"),S("--tw-grayscale"),S("--tw-hue-rotate"),S("--tw-invert"),S("--tw-opacity"),S("--tw-saturate"),S("--tw-sepia"),S("--tw-drop-shadow"),S("--tw-drop-shadow-color"),S("--tw-drop-shadow-alpha","100%",""),S("--tw-drop-shadow-size")]),C=()=>F([S("--tw-backdrop-blur"),S("--tw-backdrop-brightness"),S("--tw-backdrop-contrast"),S("--tw-backdrop-grayscale"),S("--tw-backdrop-hue-rotate"),S("--tw-backdrop-invert"),S("--tw-backdrop-opacity"),S("--tw-backdrop-saturate"),S("--tw-backdrop-sepia")]);r.functional("filter",y=>{if(!y.modifier){if(y.value===null)return[k(),n("filter",i)];if(y.value.kind==="arbitrary")return[n("filter",y.value.value)];if(y.value.value==="none")return[n("filter","none")]}}),r.functional("backdrop-filter",y=>{if(!y.modifier){if(y.value===null)return[C(),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)];if(y.value.kind==="arbitrary")return[n("-webkit-backdrop-filter",y.value.value),n("backdrop-filter",y.value.value)];if(y.value.value==="none")return[n("-webkit-backdrop-filter","none"),n("backdrop-filter","none")]}}),l("blur",{themeKeys:["--blur"],handle:y=>[k(),n("--tw-blur",`blur(${y})`),n("filter",i)],staticValues:{none:[k(),n("--tw-blur"," "),n("filter",i)]}}),l("backdrop-blur",{themeKeys:["--backdrop-blur","--blur"],handle:y=>[C(),n("--tw-backdrop-blur",`blur(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)],staticValues:{none:[C(),n("--tw-backdrop-blur"," "),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}}),l("brightness",{themeKeys:["--brightness"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,handle:y=>[k(),n("--tw-brightness",`brightness(${y})`),n("filter",i)]}),l("backdrop-brightness",{themeKeys:["--backdrop-brightness","--brightness"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,handle:y=>[C(),n("--tw-backdrop-brightness",`brightness(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}),o("brightness",()=>[{values:["0","50","75","90","95","100","105","110","125","150","200"],valueThemeKeys:["--brightness"]}]),o("backdrop-brightness",()=>[{values:["0","50","75","90","95","100","105","110","125","150","200"],valueThemeKeys:["--backdrop-brightness","--brightness"]}]),l("contrast",{themeKeys:["--contrast"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,handle:y=>[k(),n("--tw-contrast",`contrast(${y})`),n("filter",i)]}),l("backdrop-contrast",{themeKeys:["--backdrop-contrast","--contrast"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,handle:y=>[C(),n("--tw-backdrop-contrast",`contrast(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}),o("contrast",()=>[{values:["0","50","75","100","125","150","200"],valueThemeKeys:["--contrast"]}]),o("backdrop-contrast",()=>[{values:["0","50","75","100","125","150","200"],valueThemeKeys:["--backdrop-contrast","--contrast"]}]),l("grayscale",{themeKeys:["--grayscale"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,defaultValue:"100%",handle:y=>[k(),n("--tw-grayscale",`grayscale(${y})`),n("filter",i)]}),l("backdrop-grayscale",{themeKeys:["--backdrop-grayscale","--grayscale"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,defaultValue:"100%",handle:y=>[C(),n("--tw-backdrop-grayscale",`grayscale(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}),o("grayscale",()=>[{values:["0","25","50","75","100"],valueThemeKeys:["--grayscale"],hasDefaultValue:!0}]),o("backdrop-grayscale",()=>[{values:["0","25","50","75","100"],valueThemeKeys:["--backdrop-grayscale","--grayscale"],hasDefaultValue:!0}]),l("hue-rotate",{supportsNegative:!0,themeKeys:["--hue-rotate"],handleBareValue:({value:y})=>R(y)?`${y}deg`:null,handle:y=>[k(),n("--tw-hue-rotate",`hue-rotate(${y})`),n("filter",i)]}),l("backdrop-hue-rotate",{supportsNegative:!0,themeKeys:["--backdrop-hue-rotate","--hue-rotate"],handleBareValue:({value:y})=>R(y)?`${y}deg`:null,handle:y=>[C(),n("--tw-backdrop-hue-rotate",`hue-rotate(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}),o("hue-rotate",()=>[{values:["0","15","30","60","90","180"],valueThemeKeys:["--hue-rotate"]}]),o("backdrop-hue-rotate",()=>[{values:["0","15","30","60","90","180"],valueThemeKeys:["--backdrop-hue-rotate","--hue-rotate"]}]),l("invert",{themeKeys:["--invert"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,defaultValue:"100%",handle:y=>[k(),n("--tw-invert",`invert(${y})`),n("filter",i)]}),l("backdrop-invert",{themeKeys:["--backdrop-invert","--invert"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,defaultValue:"100%",handle:y=>[C(),n("--tw-backdrop-invert",`invert(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}),o("invert",()=>[{values:["0","25","50","75","100"],valueThemeKeys:["--invert"],hasDefaultValue:!0}]),o("backdrop-invert",()=>[{values:["0","25","50","75","100"],valueThemeKeys:["--backdrop-invert","--invert"],hasDefaultValue:!0}]),l("saturate",{themeKeys:["--saturate"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,handle:y=>[k(),n("--tw-saturate",`saturate(${y})`),n("filter",i)]}),l("backdrop-saturate",{themeKeys:["--backdrop-saturate","--saturate"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,handle:y=>[C(),n("--tw-backdrop-saturate",`saturate(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}),o("saturate",()=>[{values:["0","50","100","150","200"],valueThemeKeys:["--saturate"]}]),o("backdrop-saturate",()=>[{values:["0","50","100","150","200"],valueThemeKeys:["--backdrop-saturate","--saturate"]}]),l("sepia",{themeKeys:["--sepia"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,defaultValue:"100%",handle:y=>[k(),n("--tw-sepia",`sepia(${y})`),n("filter",i)]}),l("backdrop-sepia",{themeKeys:["--backdrop-sepia","--sepia"],handleBareValue:({value:y})=>R(y)?`${y}%`:null,defaultValue:"100%",handle:y=>[C(),n("--tw-backdrop-sepia",`sepia(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}),o("sepia",()=>[{values:["0","50","100"],valueThemeKeys:["--sepia"],hasDefaultValue:!0}]),o("backdrop-sepia",()=>[{values:["0","50","100"],valueThemeKeys:["--backdrop-sepia","--sepia"],hasDefaultValue:!0}]),t("drop-shadow-none",[k,["--tw-drop-shadow"," "],["filter",i]]),r.functional("drop-shadow",y=>{let N;if(y.modifier&&(y.modifier.kind==="arbitrary"?N=y.modifier.value:Ce(y.modifier.value)&&(N=`${y.modifier.value}%`)),!y.value){let _=e.get(["--drop-shadow"]),$=e.resolve(null,["--drop-shadow"]);return _===null||$===null?void 0:[k(),n("--tw-drop-shadow-alpha",N),...dt("--tw-drop-shadow-size",_,N,E=>`var(--tw-drop-shadow-color, ${E})`),n("--tw-drop-shadow",U($,",").map(E=>`drop-shadow(${E})`).join(" ")),n("filter",i)]}if(y.value.kind==="arbitrary"){let _=y.value.value;return(y.value.dataType??Q(_,["color"]))==="color"?(_=ee(_,y.modifier,e),_===null?void 0:[k(),n("--tw-drop-shadow-color",J(_,"var(--tw-drop-shadow-alpha)")),n("--tw-drop-shadow","var(--tw-drop-shadow-size)")]):y.modifier&&!N?void 0:[k(),n("--tw-drop-shadow-alpha",N),...dt("--tw-drop-shadow-size",_,N,E=>`var(--tw-drop-shadow-color, ${E})`),n("--tw-drop-shadow","var(--tw-drop-shadow-size)"),n("filter",i)]}{let _=e.get([`--drop-shadow-${y.value.value}`]),$=e.resolve(y.value.value,["--drop-shadow"]);if(_&&$)return y.modifier&&!N?void 0:N?[k(),n("--tw-drop-shadow-alpha",N),...dt("--tw-drop-shadow-size",_,N,E=>`var(--tw-drop-shadow-color, ${E})`),n("--tw-drop-shadow","var(--tw-drop-shadow-size)"),n("filter",i)]:[k(),n("--tw-drop-shadow-alpha",N),...dt("--tw-drop-shadow-size",_,N,E=>`var(--tw-drop-shadow-color, ${E})`),n("--tw-drop-shadow",U($,",").map(E=>`drop-shadow(${E})`).join(" ")),n("filter",i)]}{let _=oe(y,e,["--drop-shadow-color","--color"]);if(_)return _==="inherit"?[k(),n("--tw-drop-shadow-color","inherit"),n("--tw-drop-shadow","var(--tw-drop-shadow-size)")]:[k(),n("--tw-drop-shadow-color",J(_,"var(--tw-drop-shadow-alpha)")),n("--tw-drop-shadow","var(--tw-drop-shadow-size)")]}}),o("drop-shadow",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--drop-shadow-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(y,N)=>`${N*5}`)},{valueThemeKeys:["--drop-shadow"]}]),l("backdrop-opacity",{themeKeys:["--backdrop-opacity","--opacity"],handleBareValue:({value:y})=>Ce(y)?`${y}%`:null,handle:y=>[C(),n("--tw-backdrop-opacity",`opacity(${y})`),n("-webkit-backdrop-filter",d),n("backdrop-filter",d)]}),o("backdrop-opacity",()=>[{values:Array.from({length:21},(y,N)=>`${N*5}`),valueThemeKeys:["--backdrop-opacity","--opacity"]}])}{let i=`var(--tw-ease, ${e.resolve(null,["--default-transition-timing-function"])??"ease"})`,d=`var(--tw-duration, ${e.resolve(null,["--default-transition-duration"])??"0s"})`;l("transition",{defaultValue:"color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events",themeKeys:["--transition-property"],handle:k=>[n("transition-property",k),n("transition-timing-function",i),n("transition-duration",d)],staticValues:{none:[n("transition-property","none")],all:[n("transition-property","all"),n("transition-timing-function",i),n("transition-duration",d)],colors:[n("transition-property","color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to"),n("transition-timing-function",i),n("transition-duration",d)],opacity:[n("transition-property","opacity"),n("transition-timing-function",i),n("transition-duration",d)],shadow:[n("transition-property","box-shadow"),n("transition-timing-function",i),n("transition-duration",d)],transform:[n("transition-property","transform, translate, scale, rotate"),n("transition-timing-function",i),n("transition-duration",d)]}}),t("transition-discrete",[["transition-behavior","allow-discrete"]]),t("transition-normal",[["transition-behavior","normal"]]),l("delay",{handleBareValue:({value:k})=>R(k)?`${k}ms`:null,themeKeys:["--transition-delay"],handle:k=>[n("transition-delay",k)]});{let k=()=>F([S("--tw-duration")]);t("duration-initial",[k,["--tw-duration","initial"]]),r.functional("duration",C=>{if(C.modifier||!C.value)return;let y=null;if(C.value.kind==="arbitrary"?y=C.value.value:(y=e.resolve(C.value.fraction??C.value.value,["--transition-duration"]),y===null&&R(C.value.value)&&(y=`${C.value.value}ms`)),y!==null)return[k(),n("--tw-duration",y),n("transition-duration",y)]})}o("delay",()=>[{values:["75","100","150","200","300","500","700","1000"],valueThemeKeys:["--transition-delay"]}]),o("duration",()=>[{values:["75","100","150","200","300","500","700","1000"],valueThemeKeys:["--transition-duration"]}])}{let i=()=>F([S("--tw-ease")]);l("ease",{themeKeys:["--ease"],handle:d=>[i(),n("--tw-ease",d),n("transition-timing-function",d)],staticValues:{initial:[i(),n("--tw-ease","initial")],linear:[i(),n("--tw-ease","linear"),n("transition-timing-function","linear")]}})}t("will-change-auto",[["will-change","auto"]]),t("will-change-scroll",[["will-change","scroll-position"]]),t("will-change-contents",[["will-change","contents"]]),t("will-change-transform",[["will-change","transform"]]),l("will-change",{themeKeys:[],handle:i=>[n("will-change",i)]}),t("content-none",[["--tw-content","none"],["content","none"]]),l("content",{themeKeys:["--content"],handle:i=>[F([S("--tw-content",'""')]),n("--tw-content",i),n("content","var(--tw-content)")]});{let i="var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)",d=()=>F([S("--tw-contain-size"),S("--tw-contain-layout"),S("--tw-contain-paint"),S("--tw-contain-style")]);t("contain-none",[["contain","none"]]),t("contain-content",[["contain","content"]]),t("contain-strict",[["contain","strict"]]),t("contain-size",[d,["--tw-contain-size","size"],["contain",i]]),t("contain-inline-size",[d,["--tw-contain-size","inline-size"],["contain",i]]),t("contain-layout",[d,["--tw-contain-layout","layout"],["contain",i]]),t("contain-paint",[d,["--tw-contain-paint","paint"],["contain",i]]),t("contain-style",[d,["--tw-contain-style","style"],["contain",i]]),l("contain",{themeKeys:[],handle:k=>[n("contain",k)]})}t("forced-color-adjust-none",[["forced-color-adjust","none"]]),t("forced-color-adjust-auto",[["forced-color-adjust","auto"]]),a("leading",["--leading","--spacing"],i=>[F([S("--tw-leading")]),n("--tw-leading",i),n("line-height",i)],{staticValues:{none:[F([S("--tw-leading")]),n("--tw-leading","1"),n("line-height","1")]}}),l("tracking",{supportsNegative:!0,themeKeys:["--tracking"],handle:i=>[F([S("--tw-tracking")]),n("--tw-tracking",i),n("letter-spacing",i)]}),t("antialiased",[["-webkit-font-smoothing","antialiased"],["-moz-osx-font-smoothing","grayscale"]]),t("subpixel-antialiased",[["-webkit-font-smoothing","auto"],["-moz-osx-font-smoothing","auto"]]);{let i="var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)",d=()=>F([S("--tw-ordinal"),S("--tw-slashed-zero"),S("--tw-numeric-figure"),S("--tw-numeric-spacing"),S("--tw-numeric-fraction")]);t("normal-nums",[["font-variant-numeric","normal"]]),t("ordinal",[d,["--tw-ordinal","ordinal"],["font-variant-numeric",i]]),t("slashed-zero",[d,["--tw-slashed-zero","slashed-zero"],["font-variant-numeric",i]]),t("lining-nums",[d,["--tw-numeric-figure","lining-nums"],["font-variant-numeric",i]]),t("oldstyle-nums",[d,["--tw-numeric-figure","oldstyle-nums"],["font-variant-numeric",i]]),t("proportional-nums",[d,["--tw-numeric-spacing","proportional-nums"],["font-variant-numeric",i]]),t("tabular-nums",[d,["--tw-numeric-spacing","tabular-nums"],["font-variant-numeric",i]]),t("diagonal-fractions",[d,["--tw-numeric-fraction","diagonal-fractions"],["font-variant-numeric",i]]),t("stacked-fractions",[d,["--tw-numeric-fraction","stacked-fractions"],["font-variant-numeric",i]])}{let i=()=>F([S("--tw-outline-style","solid")]);r.static("outline-hidden",()=>[n("--tw-outline-style","none"),n("outline-style","none"),M("@media","(forced-colors: active)",[n("outline","2px solid transparent"),n("outline-offset","2px")])]),t("outline-none",[["--tw-outline-style","none"],["outline-style","none"]]),t("outline-solid",[["--tw-outline-style","solid"],["outline-style","solid"]]),t("outline-dashed",[["--tw-outline-style","dashed"],["outline-style","dashed"]]),t("outline-dotted",[["--tw-outline-style","dotted"],["outline-style","dotted"]]),t("outline-double",[["--tw-outline-style","double"],["outline-style","double"]]),r.functional("outline",d=>{if(d.value===null){if(d.modifier)return;let k=e.get(["--default-outline-width"])??"1px";return[i(),n("outline-style","var(--tw-outline-style)"),n("outline-width",k)]}if(d.value.kind==="arbitrary"){let k=d.value.value;switch(d.value.dataType??Q(k,["color","length","number","percentage"])){case"length":case"number":case"percentage":return d.modifier?void 0:[i(),n("outline-style","var(--tw-outline-style)"),n("outline-width",k)];default:return k=ee(k,d.modifier,e),k===null?void 0:[n("outline-color",k)]}}{let k=oe(d,e,["--outline-color","--color"]);if(k)return[n("outline-color",k)]}{if(d.modifier)return;let k=e.resolve(d.value.value,["--outline-width"]);if(k)return[i(),n("outline-style","var(--tw-outline-style)"),n("outline-width",k)];if(R(d.value.value))return[i(),n("outline-style","var(--tw-outline-style)"),n("outline-width",`${d.value.value}px`)]}}),o("outline",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--outline-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(d,k)=>`${k*5}`),hasDefaultValue:!0},{values:["0","1","2","4","8"],valueThemeKeys:["--outline-width"]}]),l("outline-offset",{supportsNegative:!0,themeKeys:["--outline-offset"],handleBareValue:({value:d})=>R(d)?`${d}px`:null,handle:d=>[n("outline-offset",d)]}),o("outline-offset",()=>[{supportsNegative:!0,values:["0","1","2","4","8"],valueThemeKeys:["--outline-offset"]}])}l("opacity",{themeKeys:["--opacity"],handleBareValue:({value:i})=>Ce(i)?`${i}%`:null,handle:i=>[n("opacity",i)]}),o("opacity",()=>[{values:Array.from({length:21},(i,d)=>`${d*5}`),valueThemeKeys:["--opacity"]}]),l("underline-offset",{supportsNegative:!0,themeKeys:["--text-underline-offset"],handleBareValue:({value:i})=>R(i)?`${i}px`:null,handle:i=>[n("text-underline-offset",i)],staticValues:{auto:[n("text-underline-offset","auto")]}}),o("underline-offset",()=>[{supportsNegative:!0,values:["0","1","2","4","8"],valueThemeKeys:["--text-underline-offset"]}]),r.functional("text",i=>{if(i.value){if(i.value.kind==="arbitrary"){let d=i.value.value;switch(i.value.dataType??Q(d,["color","length","percentage","absolute-size","relative-size"])){case"size":case"length":case"percentage":case"absolute-size":case"relative-size":{if(i.modifier){let C=i.modifier.kind==="arbitrary"?i.modifier.value:e.resolve(i.modifier.value,["--leading"]);if(!C&&le(i.modifier.value)){if(!e.resolve(null,["--spacing"]))return null;C=`--spacing(${i.modifier.value})`}return!C&&i.modifier.value==="none"&&(C="1"),C?[n("font-size",d),n("line-height",C)]:null}return[n("font-size",d)]}default:return d=ee(d,i.modifier,e),d===null?void 0:[n("color",d)]}}{let d=oe(i,e,["--text-color","--color"]);if(d)return[n("color",d)]}{let d=e.resolveWith(i.value.value,["--text"],["--line-height","--letter-spacing","--font-weight"]);if(d){let[k,C={}]=Array.isArray(d)?d:[d];if(i.modifier){let y=i.modifier.kind==="arbitrary"?i.modifier.value:e.resolve(i.modifier.value,["--leading"]);if(!y&&le(i.modifier.value)){if(!e.resolve(null,["--spacing"]))return null;y=`--spacing(${i.modifier.value})`}if(!y&&i.modifier.value==="none"&&(y="1"),!y)return null;let N=[n("font-size",k)];return y&&N.push(n("line-height",y)),N}return typeof C=="string"?[n("font-size",k),n("line-height",C)]:[n("font-size",k),n("line-height",C["--line-height"]?`var(--tw-leading, ${C["--line-height"]})`:void 0),n("letter-spacing",C["--letter-spacing"]?`var(--tw-tracking, ${C["--letter-spacing"]})`:void 0),n("font-weight",C["--font-weight"]?`var(--tw-font-weight, ${C["--font-weight"]})`:void 0)]}}}}),o("text",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--text-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(i,d)=>`${d*5}`)},{values:[],valueThemeKeys:["--text"],modifiers:[],modifierThemeKeys:["--leading"]}]);let I=()=>F([S("--tw-text-shadow-color"),S("--tw-text-shadow-alpha","100%","")]);t("text-shadow-initial",[I,["--tw-text-shadow-color","initial"]]),r.functional("text-shadow",i=>{let d;if(i.modifier&&(i.modifier.kind==="arbitrary"?d=i.modifier.value:Ce(i.modifier.value)&&(d=`${i.modifier.value}%`)),!i.value){let k=e.get(["--text-shadow"]);return k===null?void 0:[I(),n("--tw-text-shadow-alpha",d),...ye("text-shadow",k,d,C=>`var(--tw-text-shadow-color, ${C})`)]}if(i.value.kind==="arbitrary"){let k=i.value.value;return(i.value.dataType??Q(k,["color"]))==="color"?(k=ee(k,i.modifier,e),k===null?void 0:[I(),n("--tw-text-shadow-color",J(k,"var(--tw-text-shadow-alpha)"))]):[I(),n("--tw-text-shadow-alpha",d),...ye("text-shadow",k,d,y=>`var(--tw-text-shadow-color, ${y})`)]}switch(i.value.value){case"none":return i.modifier?void 0:[I(),n("text-shadow","none")];case"inherit":return i.modifier?void 0:[I(),n("--tw-text-shadow-color","inherit")]}{let k=e.get([`--text-shadow-${i.value.value}`]);if(k)return[I(),n("--tw-text-shadow-alpha",d),...ye("text-shadow",k,d,C=>`var(--tw-text-shadow-color, ${C})`)]}{let k=oe(i,e,["--text-shadow-color","--color"]);if(k)return[I(),n("--tw-text-shadow-color",J(k,"var(--tw-text-shadow-alpha)"))]}}),o("text-shadow",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--text-shadow-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(i,d)=>`${d*5}`)},{values:["none"]},{valueThemeKeys:["--text-shadow"],modifiers:Array.from({length:21},(i,d)=>`${d*5}`),hasDefaultValue:e.get(["--text-shadow"])!==null}]);{let y=function($){return`var(--tw-ring-inset,) 0 0 0 calc(${$} + var(--tw-ring-offset-width)) var(--tw-ring-color, ${C})`},N=function($){return`inset 0 0 0 ${$} var(--tw-inset-ring-color, currentcolor)`};var X=y,te=N;let i=["var(--tw-inset-shadow)","var(--tw-inset-ring-shadow)","var(--tw-ring-offset-shadow)","var(--tw-ring-shadow)","var(--tw-shadow)"].join(", "),d="0 0 #0000",k=()=>F([S("--tw-shadow",d),S("--tw-shadow-color"),S("--tw-shadow-alpha","100%",""),S("--tw-inset-shadow",d),S("--tw-inset-shadow-color"),S("--tw-inset-shadow-alpha","100%",""),S("--tw-ring-color"),S("--tw-ring-shadow",d),S("--tw-inset-ring-color"),S("--tw-inset-ring-shadow",d),S("--tw-ring-inset"),S("--tw-ring-offset-width","0px",""),S("--tw-ring-offset-color","#fff"),S("--tw-ring-offset-shadow",d)]);t("shadow-initial",[k,["--tw-shadow-color","initial"]]),r.functional("shadow",$=>{let E;if($.modifier&&($.modifier.kind==="arbitrary"?E=$.modifier.value:Ce($.modifier.value)&&(E=`${$.modifier.value}%`)),!$.value){let j=e.get(["--shadow"]);return j===null?void 0:[k(),n("--tw-shadow-alpha",E),...ye("--tw-shadow",j,E,fe=>`var(--tw-shadow-color, ${fe})`),n("box-shadow",i)]}if($.value.kind==="arbitrary"){let j=$.value.value;return($.value.dataType??Q(j,["color"]))==="color"?(j=ee(j,$.modifier,e),j===null?void 0:[k(),n("--tw-shadow-color",J(j,"var(--tw-shadow-alpha)"))]):[k(),n("--tw-shadow-alpha",E),...ye("--tw-shadow",j,E,Vt=>`var(--tw-shadow-color, ${Vt})`),n("box-shadow",i)]}switch($.value.value){case"none":return $.modifier?void 0:[k(),n("--tw-shadow",d),n("box-shadow",i)];case"inherit":return $.modifier?void 0:[k(),n("--tw-shadow-color","inherit")]}{let j=e.get([`--shadow-${$.value.value}`]);if(j)return[k(),n("--tw-shadow-alpha",E),...ye("--tw-shadow",j,E,fe=>`var(--tw-shadow-color, ${fe})`),n("box-shadow",i)]}{let j=oe($,e,["--box-shadow-color","--color"]);if(j)return[k(),n("--tw-shadow-color",J(j,"var(--tw-shadow-alpha)"))]}}),o("shadow",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--box-shadow-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},($,E)=>`${E*5}`)},{values:["none"]},{valueThemeKeys:["--shadow"],modifiers:Array.from({length:21},($,E)=>`${E*5}`),hasDefaultValue:e.get(["--shadow"])!==null}]),t("inset-shadow-initial",[k,["--tw-inset-shadow-color","initial"]]),r.functional("inset-shadow",$=>{let E;if($.modifier&&($.modifier.kind==="arbitrary"?E=$.modifier.value:Ce($.modifier.value)&&(E=`${$.modifier.value}%`)),!$.value){let j=e.get(["--inset-shadow"]);return j===null?void 0:[k(),n("--tw-inset-shadow-alpha",E),...ye("--tw-inset-shadow",j,E,fe=>`var(--tw-inset-shadow-color, ${fe})`),n("box-shadow",i)]}if($.value.kind==="arbitrary"){let j=$.value.value;return($.value.dataType??Q(j,["color"]))==="color"?(j=ee(j,$.modifier,e),j===null?void 0:[k(),n("--tw-inset-shadow-color",J(j,"var(--tw-inset-shadow-alpha)"))]):[k(),n("--tw-inset-shadow-alpha",E),...ye("--tw-inset-shadow",j,E,Vt=>`var(--tw-inset-shadow-color, ${Vt})`,"inset"),n("box-shadow",i)]}switch($.value.value){case"none":return $.modifier?void 0:[k(),n("--tw-inset-shadow",`inset ${d}`),n("box-shadow",i)];case"inherit":return $.modifier?void 0:[k(),n("--tw-inset-shadow-color","inherit")]}{let j=e.get([`--inset-shadow-${$.value.value}`]);if(j)return[k(),n("--tw-inset-shadow-alpha",E),...ye("--tw-inset-shadow",j,E,fe=>`var(--tw-inset-shadow-color, ${fe})`),n("box-shadow",i)]}{let j=oe($,e,["--box-shadow-color","--color"]);if(j)return[k(),n("--tw-inset-shadow-color",J(j,"var(--tw-inset-shadow-alpha)"))]}}),o("inset-shadow",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--box-shadow-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},($,E)=>`${E*5}`)},{values:["none"]},{valueThemeKeys:["--inset-shadow"],modifiers:Array.from({length:21},($,E)=>`${E*5}`),hasDefaultValue:e.get(["--inset-shadow"])!==null}]),t("ring-inset",[k,["--tw-ring-inset","inset"]]);let C=e.get(["--default-ring-color"])??"currentcolor";r.functional("ring",$=>{if(!$.value){if($.modifier)return;let E=e.get(["--default-ring-width"])??"1px";return[k(),n("--tw-ring-shadow",y(E)),n("box-shadow",i)]}if($.value.kind==="arbitrary"){let E=$.value.value;return($.value.dataType??Q(E,["color","length"]))==="length"?$.modifier?void 0:[k(),n("--tw-ring-shadow",y(E)),n("box-shadow",i)]:(E=ee(E,$.modifier,e),E===null?void 0:[n("--tw-ring-color",E)])}{let E=oe($,e,["--ring-color","--color"]);if(E)return[n("--tw-ring-color",E)]}{if($.modifier)return;let E=e.resolve($.value.value,["--ring-width"]);if(E===null&&R($.value.value)&&(E=`${$.value.value}px`),E)return[k(),n("--tw-ring-shadow",y(E)),n("box-shadow",i)]}}),o("ring",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--ring-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},($,E)=>`${E*5}`)},{values:["0","1","2","4","8"],valueThemeKeys:["--ring-width"],hasDefaultValue:!0}]),r.functional("inset-ring",$=>{if(!$.value)return $.modifier?void 0:[k(),n("--tw-inset-ring-shadow",N("1px")),n("box-shadow",i)];if($.value.kind==="arbitrary"){let E=$.value.value;return($.value.dataType??Q(E,["color","length"]))==="length"?$.modifier?void 0:[k(),n("--tw-inset-ring-shadow",N(E)),n("box-shadow",i)]:(E=ee(E,$.modifier,e),E===null?void 0:[n("--tw-inset-ring-color",E)])}{let E=oe($,e,["--ring-color","--color"]);if(E)return[n("--tw-inset-ring-color",E)]}{if($.modifier)return;let E=e.resolve($.value.value,["--ring-width"]);if(E===null&&R($.value.value)&&(E=`${$.value.value}px`),E)return[k(),n("--tw-inset-ring-shadow",N(E)),n("box-shadow",i)]}}),o("inset-ring",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--ring-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},($,E)=>`${E*5}`)},{values:["0","1","2","4","8"],valueThemeKeys:["--ring-width"],hasDefaultValue:!0}]);let _="var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)";r.functional("ring-offset",$=>{if($.value){if($.value.kind==="arbitrary"){let E=$.value.value;return($.value.dataType??Q(E,["color","length"]))==="length"?$.modifier?void 0:[n("--tw-ring-offset-width",E),n("--tw-ring-offset-shadow",_)]:(E=ee(E,$.modifier,e),E===null?void 0:[n("--tw-ring-offset-color",E)])}{let E=e.resolve($.value.value,["--ring-offset-width"]);if(E)return $.modifier?void 0:[n("--tw-ring-offset-width",E),n("--tw-ring-offset-shadow",_)];if(R($.value.value))return $.modifier?void 0:[n("--tw-ring-offset-width",`${$.value.value}px`),n("--tw-ring-offset-shadow",_)]}{let E=oe($,e,["--ring-offset-color","--color"]);if(E)return[n("--tw-ring-offset-color",E)]}}})}return o("ring-offset",()=>[{values:["current","inherit","transparent"],valueThemeKeys:["--ring-offset-color","--color"],modifierThemeKeys:["--opacity"],modifiers:Array.from({length:21},(i,d)=>`${d*5}`)},{values:["0","1","2","4","8"],valueThemeKeys:["--ring-offset-width"]}]),r.functional("@container",i=>{let d=null;if(i.value===null?d="inline-size":i.value.kind==="arbitrary"?d=i.value.value:i.value.kind==="named"&&i.value.value==="normal"?d="normal":i.value.kind==="named"&&i.value.value==="size"&&(d="size"),d!==null)return i.modifier?[n("container-type",d),n("container-name",i.modifier.value)]:[n("container-type",d)]}),o("@container",()=>[{values:["normal"],valueThemeKeys:[],hasDefaultValue:!0}]),r}var er=["number","integer","ratio","percentage"];function co(e){let r=ke(e.params);return An(r)?o=>{let t={"--value":{usedSpacingInteger:!1,usedSpacingNumber:!1,themeKeys:new Set,literals:new Set},"--modifier":{usedSpacingInteger:!1,usedSpacingNumber:!1,themeKeys:new Set,literals:new Set}};O(e.nodes,l=>{if(l.kind!=="declaration"||!l.value||!l.value.includes("--value(")&&!l.value.includes("--modifier("))return;let s=q(l.value);O(s,a=>{if(a.kind!=="function")return;if(a.value==="--spacing"&&!(t["--modifier"].usedSpacingNumber&&t["--value"].usedSpacingNumber))return O(a.nodes,h=>{if(h.kind!=="function"||h.value!=="--value"&&h.value!=="--modifier")return;let f=h.value;for(let u of h.nodes)if(u.kind==="word"){if(u.value==="integer")t[f].usedSpacingInteger||=!0;else if(u.value==="number"&&(t[f].usedSpacingNumber||=!0,t["--modifier"].usedSpacingNumber&&t["--value"].usedSpacingNumber))return V.Stop}}),V.Continue;if(a.value!=="--value"&&a.value!=="--modifier")return;let m=U(Z(a.nodes),",");for(let[h,f]of m.entries())f=f.replace(/\\\*/g,"*"),f=f.replace(/--(.*?)\s--(.*?)/g,"--$1-*--$2"),f=f.replace(/\s+/g,""),f=f.replace(/(-\*){2,}/g,"-*"),f[0]==="-"&&f[1]==="-"&&!f.includes("(")&&!f.includes("-*")&&(f+="-*"),m[h]=f;a.nodes=q(m.join(","));for(let h of a.nodes)if(h.kind==="word"&&(h.value[0]==='"'||h.value[0]==="'")&&h.value[0]===h.value[h.value.length-1]){let f=h.value.slice(1,-1);t[a.value].literals.add(f)}else if(h.kind==="word"&&h.value[0]==="-"&&h.value[1]==="-"){let f=h.value.replace(/-\*.*$/g,"");t[a.value].themeKeys.add(f)}else if(h.kind==="word"&&!(h.value[0]==="["&&h.value[h.value.length-1]==="]")&&!er.includes(h.value)){console.warn(`Unsupported bare value data type: "${h.value}". +Only valid data types are: ${er.map(b=>`"${b}"`).join(", ")}. +`);let f=h.value,u=structuredClone(a),p="\xB6";O(u.nodes,b=>{if(b.kind==="word"&&b.value===f)return V.ReplaceSkip({kind:"word",value:p})});let c="^".repeat(Z([h]).length),v=Z([u]).indexOf(p),g=["```css",Z([a])," ".repeat(v)+c,"```"].join(` +`);console.warn(g)}}),l.value=Z(s)}),o.utilities.functional(r.slice(0,-2),l=>{let s=re(e),a=l.value,m=l.modifier,h=!1,f=!1,u=!1,p=!1,c=new Map,v=!1;if(O([s],(g,b)=>{let x=b.parent;if(x?.kind!=="rule"&&x?.kind!=="at-rule"||g.kind!=="declaration"||!g.value)return;let T=!1,A=q(g.value);if(O(A,w=>{if(w.kind==="function"){if(w.value==="--value"){h=!0;let K=lo(a,w,o);return K?(f=!0,K.ratio?v=!0:c.set(g,x),V.ReplaceSkip(K.nodes)):(T=!0,V.Stop)}else if(w.value==="--modifier"){u=!0;let K=lo(m,w,o);return K?(p=!0,V.ReplaceSkip(K.nodes)):(T=!0,V.Stop)}}}),T)return V.ReplaceSkip([]);g.value=Z(A)}),!h||!f||u&&!p&&m!==null||v&&p||m&&!v&&!p)return null;if(v)for(let[g,b]of c){let x=b.nodes.indexOf(g);x!==-1&&b.nodes.splice(x,1)}return s.nodes}),o.utilities.suggest(r.slice(0,-2),()=>{let l=[],s=[];for(let[a,{literals:m,usedSpacingNumber:h,usedSpacingInteger:f,themeKeys:u}]of[[l,t["--value"]],[s,t["--modifier"]]]){for(let p of m)a.push(p);if(h)a.push(...gt);else if(f)for(let p of gt)R(p)&&a.push(p);for(let p of o.theme.keysInNamespaces(u))a.push(p.replace(so,(c,v,g)=>`${v}.${g}`))}return[{values:l,modifiers:s}]})}:xn(r)?o=>{o.utilities.static(r,()=>e.nodes.map(re))}:null}function lo(e,r,o){if(e===null){for(let t of r.nodes)if(t.kind==="function"&&t.value==="--default")return{nodes:t.nodes};return}for(let t of r.nodes){if(e.kind==="named"&&t.kind==="word"&&(t.value[0]==="'"||t.value[0]==='"')&&t.value[t.value.length-1]===t.value[0]&&t.value.slice(1,-1)===e.value)return{nodes:q(e.value)};if(e.kind==="named"&&t.kind==="word"&&t.value[0]==="-"&&t.value[1]==="-"){let l=t.value;if(l.endsWith("-*")){l=l.slice(0,-2);let s=o.theme.resolve(e.value,[l]);if(s)return{nodes:q(s)}}else{let s=l.split("-*");if(s.length<=1)continue;let a=[s.shift()],m=o.theme.resolveWith(e.value,a,s);if(m){let[,h={}]=m;{let f=h[s.pop()];if(f)return{nodes:q(f)}}}}}else if(e.kind==="named"&&t.kind==="word"){if(!er.includes(t.value))continue;let l=t.value==="ratio"&&"fraction"in e?e.fraction:e.value;if(!l)continue;let s=Q(l,[t.value]);if(s===null)continue;if(s==="ratio"){let[a,m]=U(l,"/").map(Number);if(!R(a)||!R(m))continue}else{if(s==="number"&&!le(l))continue;if(s==="percentage"&&!R(l.slice(0,-1)))continue}if(s==="ratio"){let[a,m]=U(l,"/");return{nodes:q(`${a.trim()} / ${m.trim()}`),ratio:!0}}return{nodes:q(l),ratio:!1}}else if(e.kind==="arbitrary"&&t.kind==="word"&&t.value[0]==="["&&t.value[t.value.length-1]==="]"){let l=t.value.slice(1,-1);if(l==="*")return{nodes:q(e.value)};if("dataType"in e&&e.dataType&&e.dataType!==l)continue;if("dataType"in e&&e.dataType)return{nodes:q(e.value)};if(Q(e.value,[l])!==null)return{nodes:q(e.value)}}}}function ye(e,r,o,t,l=""){let s=!1,a=Ze(r,h=>o==null?t(h):h.startsWith("current")?t(J(h,o)):((h.startsWith("var(")||o.startsWith("var("))&&(s=!0),t(ao(h,o))));function m(h){return l?U(h,",").map(f=>l.trim()+" "+f.trim()).join(", "):h}return s?[n(e,m(Ze(r,t))),Y("@supports (color: lab(from red l a b))",[n(e,m(a))])]:[n(e,m(a))]}function dt(e,r,o,t,l=""){let s=!1,a=U(r,",").map(m=>Ze(m,h=>o==null?t(h):h.startsWith("current")?t(J(h,o)):((h.startsWith("var(")||o.startsWith("var("))&&(s=!0),t(ao(h,o))))).map(m=>`drop-shadow(${m})`).join(" ");return s?[n(e,l+U(r,",").map(m=>`drop-shadow(${Ze(m,t)})`).join(" ")),Y("@supports (color: lab(from red l a b))",[n(e,l+a)])]:[n(e,l+a)]}var fo=/^-?[a-z][a-zA-Z0-9_-]*/,dn=37,mn=47,hn=46,gn=97,kn=122,vn=65,bn=90,mt=48,ht=57,wn=95,yn=45;function xn(e){let r=fo.exec(e);if(r===null)return!1;let o=r[0],t=e.slice(o.length);if(t.length===0&&o.endsWith("-"))return!1;if(t.length===0)return!0;let l=!1;for(let s=0;sht)return!1;break}case mn:{if(s===t.length-1||l)return!1;l=!0;break}case hn:{let h=(t[s-1]||o[o.length-1]||"").charCodeAt(0);if(hht)return!1;let u=(t[s+1]||"").charCodeAt(0);if(uht)return!1;break}case wn:case yn:continue;default:{if(a>=gn&&a<=kn||a>=vn&&a<=bn||a>=mt&&a<=ht)continue;return!1}}}return!0}function An(e){if(!e.endsWith("-*"))return!1;e=e.slice(0,-2);let r=fo.exec(e);if(r===null)return!1;let o=r[0];return e.slice(o.length).length===0}var tr={"--alpha":Cn,"--spacing":Sn,"--theme":Nn,theme:Tn};function Cn(e,r,o,...t){let[l,s]=U(o,"/").map(a=>a.trim());if(!l||!s)throw new Error(`The --alpha(\u2026) function requires a color and an alpha value, e.g.: \`--alpha(${l||"var(--my-color)"} / ${s||"50%"})\``);if(t.length>0)throw new Error(`The --alpha(\u2026) function only accepts one argument, e.g.: \`--alpha(${l||"var(--my-color)"} / ${s||"50%"})\``);return J(l,s)}function Sn(e,r,o,...t){if(!o)throw new Error("The --spacing(\u2026) function requires an argument, but received none.");if(t.length>0)throw new Error(`The --spacing(\u2026) function only accepts a single argument, but received ${t.length+1}.`);let l=e.theme.resolve(null,["--spacing"]);if(!l)throw new Error("The --spacing(\u2026) function requires that the `--spacing` theme variable exists, but it was not found.");let s=Ge.get(o);if(s){if(s[0]===0)return"0px";if(s[0]===1)return l}return`calc(${l} * ${o})`}function Nn(e,r,o,...t){if(!o.startsWith("--"))throw new Error("The --theme(\u2026) function can only be used with CSS variables from your theme.");let l=!1;o.endsWith(" inline")&&(l=!0,o=o.slice(0,-7)),r.kind==="at-rule"&&(l=!0);let s=e.resolveThemeValue(o,l);if(!s){if(t.length>0)return t.join(", ");throw new Error(`Could not resolve value for theme function: \`theme(${o})\`. Consider checking if the variable name is correct or provide a fallback value to silence this error.`)}if(t.length===0)return s;let a=t.join(", ");if(a==="initial")return s;if(s==="initial")return a;if(s.startsWith("var(")||s.startsWith("theme(")||s.startsWith("--theme(")){let m=q(s);return En(m,a),Z(m)}return s}function Tn(e,r,o,...t){o=$n(o);let l=e.resolveThemeValue(o);if(!l&&t.length>0)return t.join(", ");if(!l)throw new Error(`Could not resolve value for theme function: \`theme(${o})\`. Consider checking if the path is correct or provide a fallback value to silence this error.`);return l}var po=new RegExp(Object.keys(tr).map(e=>`${e}\\(`).join("|"));function Le(e,r){let o=0;return O(e,t=>{if(t.kind==="declaration"&&t.value&&po.test(t.value)){o|=8,t.value=mo(t.value,t,r);return}t.kind==="at-rule"&&(t.name==="@media"||t.name==="@custom-media"||t.name==="@container"||t.name==="@supports")&&po.test(t.params)&&(o|=8,t.params=mo(t.params,t,r))}),o}function mo(e,r,o){let t=q(e);return O(t,l=>{if(l.kind==="function"&&l.value in tr){let s=U(Z(l.nodes).trim(),",").map(m=>m.trim()),a=tr[l.value](o,r,...s);return V.Replace(q(a))}}),Z(t)}function $n(e){if(e[0]!=="'"&&e[0]!=='"')return e;let r="",o=e[0];for(let t=1;t{if(o.kind==="function"&&!(o.value!=="var"&&o.value!=="theme"&&o.value!=="--theme"))if(o.nodes.length===1)o.nodes.push({kind:"word",value:`, ${r}`});else{let t=o.nodes[o.nodes.length-1];t.kind==="word"&&t.value==="initial"&&(t.value=r)}})}function ho(){return[]}function go(){return[]}function ko(){return[]}function vo(e,r){let{astNodes:o,nodeSorting:t}=Se(Array.from(r),e),l=new Map(r.map(a=>[a,null])),s=0n;for(let a of o){let m=t.get(a)?.candidate;m&&l.set(m,l.get(m)??s++)}return r.map(a=>[a,l.get(a)??null])}function vt(e){if(e[0]!=="["||e[e.length-1]!=="]")return null;let r=1,o=r,t=e.length-1;for(;Ue(e.charCodeAt(r));)r++;{for(o=r;r=65&&u<=90)&&!(u>=97&&u<=122)&&!(u>=48&&u<=57)&&!(u===45||u===95)&&!(u>=128))break}if(o===r)return null}let l=e.slice(o,r);for(;Ue(e.charCodeAt(r));)r++;if(r===t)return{attribute:l,operator:null,quote:null,value:null,sensitivity:null};let s=null,a=e.charCodeAt(r);if(a===61)s="=",r++;else if((a===126||a===124||a===94||a===36||a===42)&&e.charCodeAt(r+1)===61)s=e[r]+"=",r+=2;else return null;for(;Ue(e.charCodeAt(r));)r++;if(r===t)return null;let m="",h=null;if(a=e.charCodeAt(r),a===39||a===34){h=e[r],r++,o=r;for(let u=r;u{a.kind==="rule"?l.push(a.selector):a.kind==="at-rule"&&a.name==="@variant"?s=!0:a.kind==="at-rule"&&a.name!=="@slot"&&l.push(`${a.name} ${a.params}`)}),this.static(r,a=>{let m=o.map(re);s&&Ye(m,t),or(m,a.nodes),a.nodes=m},{compounds:Ve(l)})}functional(r,o,{compounds:t,order:l}={}){this.set(r,{kind:"functional",applyFn:o,compoundsWith:0,compounds:t??2,order:l})}compound(r,o,t,{compounds:l,order:s}={}){this.set(r,{kind:"compound",applyFn:t,compoundsWith:o,compounds:l??2,order:s})}group(r,o){this.groupOrder=this.nextOrder(),o&&this.compareFns.set(this.groupOrder,o),r(),this.groupOrder=null}has(r){return this.variants.has(r)}get(r){return this.variants.get(r)}kind(r){return this.variants.get(r)?.kind}compoundsWith(r,o){let t=this.variants.get(r),l=typeof o=="string"?this.variants.get(o):o.kind==="arbitrary"?{compounds:Ve([o.selector])}:this.variants.get(o.root);return!(!t||!l||t.kind!=="compound"||l.compounds===0||t.compoundsWith===0||(t.compoundsWith&l.compounds)===0)}suggest(r,o){this.completions.set(r,o)}getCompletions(r){return this.completions.get(r)?.()??[]}compare(r,o){if(r===o)return 0;if(r===null)return-1;if(o===null)return 1;if(r.kind==="arbitrary"&&o.kind==="arbitrary")return r.selector{c.nodes=u.map(v=>Y(v,c.nodes))},{compounds:p})}o("*",[":is(& > *)"],{compounds:0}),o("**",[":is(& *)"],{compounds:0});function t(f,u){return u.map(p=>{if(f==="@container"){let c=q(p.trim());return c.length>=1&&c[0].kind==="function"?`not ${p}`:c.length>=3&&c[0].kind==="word"&&c[0].value==="not"&&c[2].kind==="function"?(c.splice(0,2),Z(c)):c.length>=5&&c[0].kind==="word"&&c[2].kind==="word"&&c[2].value==="not"&&c[4].kind==="function"?(c.splice(2,2),Z(c)):c.length>=3&&c[0].kind==="word"&&c[0].value!=="not"&&c[2].kind==="function"?(c.splice(1,0,{kind:"separator",value:" "},{kind:"word",value:"not"}),Z(c)):`not ${p}`}else{p=p.trim();let c=U(p," ");return c[0]==="not"?c.slice(1).join(" "):`not ${p}`}})}let l=["@media","@supports","@container"];function s(f){for(let u of l){if(u!==f.name)continue;let p=U(f.params,",");return p.length>1?null:(p=t(f.name,p),M(f.name,p.join(", ")))}return null}function a(f){return f.includes("::")?null:`&:not(${U(f,",").map(p=>(p=p.replaceAll("&","*"),p)).join(", ")})`}r.compound("not",3,(f,u)=>{if(u.variant.kind==="arbitrary"&&u.variant.relative||u.modifier)return null;let p=!1;if(O([f],(c,v)=>{if(c.kind!=="rule"&&c.kind!=="at-rule")return V.Continue;if(c.nodes.length>0)return V.Continue;let g=[],b=[],x=v.path();x.push(c);for(let A of x)A.kind==="at-rule"?g.push(A):A.kind==="rule"&&b.push(A);if(g.length>1)return V.Stop;if(b.length>1)return V.Stop;let T=[];for(let A of b){let w=a(A.selector);if(!w)return p=!1,V.Stop;T.push(G(w,[]))}for(let A of g){let w=s(A);if(!w)return p=!1,V.Stop;T.push(w)}return Object.assign(f,G("&",T)),p=!0,V.Skip}),f.kind==="rule"&&f.selector==="&"&&f.nodes.length===1&&Object.assign(f,f.nodes[0]),!p)return null}),r.suggest("not",()=>Array.from(r.keys()).filter(f=>r.compoundsWith("not",f))),r.compound("group",2,(f,u)=>{if(u.variant.kind==="arbitrary"&&u.variant.relative)return null;let p=u.modifier?`:where(.${e.prefix?`${e.prefix}\\:`:""}group\\/${u.modifier.value})`:`:where(.${e.prefix?`${e.prefix}\\:`:""}group)`,c=!1;if(O([f],(v,g)=>{if(v.kind!=="rule")return V.Continue;for(let x of g.path())if(x.kind==="rule")return c=!1,V.Stop;let b=v.selector.replaceAll("&",p);U(b,",").length>1&&(b=`:is(${b})`),v.selector=`&:is(${b} *)`,c=!0}),!c)return null}),r.suggest("group",()=>Array.from(r.keys()).filter(f=>r.compoundsWith("group",f))),r.compound("peer",2,(f,u)=>{if(u.variant.kind==="arbitrary"&&u.variant.relative)return null;let p=u.modifier?`:where(.${e.prefix?`${e.prefix}\\:`:""}peer\\/${u.modifier.value})`:`:where(.${e.prefix?`${e.prefix}\\:`:""}peer)`,c=!1;if(O([f],(v,g)=>{if(v.kind!=="rule")return V.Continue;for(let x of g.path())if(x.kind==="rule")return c=!1,V.Stop;let b=v.selector.replaceAll("&",p);U(b,",").length>1&&(b=`:is(${b})`),v.selector=`&:is(${b} ~ *)`,c=!0}),!c)return null}),r.suggest("peer",()=>Array.from(r.keys()).filter(f=>r.compoundsWith("peer",f))),o("first-letter",["&::first-letter"]),o("first-line",["&::first-line"]),o("marker",["& *::marker","&::marker","& *::-webkit-details-marker","&::-webkit-details-marker"]),o("selection",["& *::selection","&::selection"]),o("file",["&::file-selector-button"]),o("placeholder",["&::placeholder"]),o("backdrop",["&::backdrop"]),o("details-content",["&::details-content"]);{let f=function(){return F([M("@property","--tw-content",[n("syntax",'"*"'),n("initial-value",'""'),n("inherits","false")])])};var m=f;r.static("before",u=>{u.nodes=[G("&::before",[f(),n("content","var(--tw-content)"),...u.nodes])]},{compounds:0}),r.static("after",u=>{u.nodes=[G("&::after",[f(),n("content","var(--tw-content)"),...u.nodes])]},{compounds:0})}o("first",["&:first-child"]),o("last",["&:last-child"]),o("only",["&:only-child"]),o("odd",["&:nth-child(odd)"]),o("even",["&:nth-child(even)"]),o("first-of-type",["&:first-of-type"]),o("last-of-type",["&:last-of-type"]),o("only-of-type",["&:only-of-type"]),o("visited",["&:visited"]),o("target",["&:target"]),o("open",["&:is([open], :popover-open, :open)"]),o("default",["&:default"]),o("checked",["&:checked"]),o("indeterminate",["&:indeterminate"]),o("placeholder-shown",["&:placeholder-shown"]),o("autofill",["&:autofill"]),o("optional",["&:optional"]),o("required",["&:required"]),o("valid",["&:valid"]),o("invalid",["&:invalid"]),o("user-valid",["&:user-valid"]),o("user-invalid",["&:user-invalid"]),o("in-range",["&:in-range"]),o("out-of-range",["&:out-of-range"]),o("read-only",["&:read-only"]),o("empty",["&:empty"]),o("focus-within",["&:focus-within"]),r.static("hover",f=>{f.nodes=[G("&:hover",[M("@media","(hover: hover)",f.nodes)])]}),o("focus",["&:focus"]),o("focus-visible",["&:focus-visible"]),o("active",["&:active"]),o("enabled",["&:enabled"]),o("disabled",["&:disabled"]),o("inert",["&:is([inert], [inert] *)"]),r.compound("in",2,(f,u)=>{if(u.modifier)return null;let p=!1;if(O([f],(c,v)=>{if(c.kind!=="rule")return V.Continue;for(let g of v.path())if(g.kind==="rule")return p=!1,V.Stop;c.selector=`:where(${c.selector.replaceAll("&","*")}) &`,p=!0}),!p)return null}),r.suggest("in",()=>Array.from(r.keys()).filter(f=>r.compoundsWith("in",f))),r.compound("has",2,(f,u)=>{if(u.modifier)return null;let p=!1;if(O([f],(c,v)=>{if(c.kind!=="rule")return V.Continue;for(let g of v.path())if(g.kind==="rule")return p=!1,V.Stop;c.selector=`&:has(${c.selector.replaceAll("&","*")})`,p=!0}),!p)return null}),r.suggest("has",()=>Array.from(r.keys()).filter(f=>r.compoundsWith("has",f))),r.functional("aria",(f,u)=>{if(!u.value||u.modifier)return null;if(u.value.kind==="arbitrary"){let p=`[aria-${bo(u.value.value)}]`;if(vt(p)===null)return null;f.nodes=[G(`&${p}`,f.nodes)]}else{let p=`[aria-${u.value.value}="true"]`;if(vt(p)===null)return null;f.nodes=[G(`&${p}`,f.nodes)]}}),r.suggest("aria",()=>["busy","checked","disabled","expanded","hidden","pressed","readonly","required","selected"]),r.functional("data",(f,u)=>{if(!u.value||u.modifier)return null;let p=`[data-${bo(u.value.value)}]`;if(vt(p)===null)return null;f.nodes=[G(`&${p}`,f.nodes)]}),r.functional("nth",(f,u)=>{if(!u.value||u.modifier||u.value.kind==="named"&&!R(u.value.value))return null;f.nodes=[G(`&:nth-child(${u.value.value})`,f.nodes)]}),r.functional("nth-last",(f,u)=>{if(!u.value||u.modifier||u.value.kind==="named"&&!R(u.value.value))return null;f.nodes=[G(`&:nth-last-child(${u.value.value})`,f.nodes)]}),r.functional("nth-of-type",(f,u)=>{if(!u.value||u.modifier||u.value.kind==="named"&&!R(u.value.value))return null;f.nodes=[G(`&:nth-of-type(${u.value.value})`,f.nodes)]}),r.functional("nth-last-of-type",(f,u)=>{if(!u.value||u.modifier||u.value.kind==="named"&&!R(u.value.value))return null;f.nodes=[G(`&:nth-last-of-type(${u.value.value})`,f.nodes)]}),r.functional("supports",(f,u)=>{if(!u.value||u.modifier)return null;let p=u.value.value;if(p===null)return null;if(/^[\w-]*\s*\(/.test(p)){let c=p.replace(/\b(and|or|not)\b/g," $1 ");f.nodes=[M("@supports",c,f.nodes)];return}p.includes(":")||(p=`${p}: var(--tw)`),(p[0]!=="("||p[p.length-1]!==")")&&(p=`(${p})`),f.nodes=[M("@supports",p,f.nodes)]},{compounds:1}),o("motion-safe",["@media (prefers-reduced-motion: no-preference)"]),o("motion-reduce",["@media (prefers-reduced-motion: reduce)"]),o("contrast-more",["@media (prefers-contrast: more)"]),o("contrast-less",["@media (prefers-contrast: less)"]);{let f=function(u,p,c,v){if(u===p)return 0;let g=v.get(u);if(g===null)return c==="asc"?-1:1;let b=v.get(p);return b===null?c==="asc"?1:-1:$e(g,b,c)};var h=f;{let u=e.namespace("--breakpoint"),p=new H(c=>{switch(c.kind){case"static":return e.resolveValue(c.root,["--breakpoint"])??null;case"functional":{if(!c.value||c.modifier)return null;let v=null;return c.value.kind==="arbitrary"?v=c.value.value:c.value.kind==="named"&&(v=e.resolveValue(c.value.value,["--breakpoint"])),!v||v.includes("var(")?null:v}case"arbitrary":case"compound":return null}});r.group(()=>{r.functional("max",(c,v)=>{if(v.modifier)return null;let g=p.get(v);if(g===null)return null;c.nodes=[M("@media",`(width < ${g})`,c.nodes)]},{compounds:1})},(c,v)=>f(c,v,"desc",p)),r.suggest("max",()=>Array.from(u.keys()).filter(c=>c!==null)),r.group(()=>{for(let[c,v]of e.namespace("--breakpoint"))c!==null&&r.static(c,g=>{g.nodes=[M("@media",`(width >= ${v})`,g.nodes)]},{compounds:1});r.functional("min",(c,v)=>{if(v.modifier)return null;let g=p.get(v);if(g===null)return null;c.nodes=[M("@media",`(width >= ${g})`,c.nodes)]},{compounds:1})},(c,v)=>f(c,v,"asc",p)),r.suggest("min",()=>Array.from(u.keys()).filter(c=>c!==null))}{let u=e.namespace("--container"),p=new H(c=>{switch(c.kind){case"functional":{if(c.value===null)return null;let v=null;return c.value.kind==="arbitrary"?v=c.value.value:c.value.kind==="named"&&(v=e.resolveValue(c.value.value,["--container"])),!v||v.includes("var(")?null:v}case"static":case"arbitrary":case"compound":return null}});r.group(()=>{r.functional("@max",(c,v)=>{let g=p.get(v);if(g===null)return null;c.nodes=[M("@container",v.modifier?`${v.modifier.value} (width < ${g})`:`(width < ${g})`,c.nodes)]},{compounds:1})},(c,v)=>f(c,v,"desc",p)),r.suggest("@max",()=>Array.from(u.keys()).filter(c=>c!==null)),r.group(()=>{r.functional("@",(c,v)=>{let g=p.get(v);if(g===null)return null;c.nodes=[M("@container",v.modifier?`${v.modifier.value} (width >= ${g})`:`(width >= ${g})`,c.nodes)]},{compounds:1}),r.functional("@min",(c,v)=>{let g=p.get(v);if(g===null)return null;c.nodes=[M("@container",v.modifier?`${v.modifier.value} (width >= ${g})`:`(width >= ${g})`,c.nodes)]},{compounds:1})},(c,v)=>f(c,v,"asc",p)),r.suggest("@min",()=>Array.from(u.keys()).filter(c=>c!==null)),r.suggest("@",()=>Array.from(u.keys()).filter(c=>c!==null))}}return o("portrait",["@media (orientation: portrait)"]),o("landscape",["@media (orientation: landscape)"]),o("ltr",['&:where(:dir(ltr), [dir="ltr"], [dir="ltr"] *)']),o("rtl",['&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)']),o("dark",["@media (prefers-color-scheme: dark)"]),o("starting",["@starting-style"]),o("print",["@media print"]),o("forced-colors",["@media (forced-colors: active)"]),o("inverted-colors",["@media (inverted-colors: inverted)"]),o("pointer-none",["@media (pointer: none)"]),o("pointer-coarse",["@media (pointer: coarse)"]),o("pointer-fine",["@media (pointer: fine)"]),o("any-pointer-none",["@media (any-pointer: none)"]),o("any-pointer-coarse",["@media (any-pointer: coarse)"]),o("any-pointer-fine",["@media (any-pointer: fine)"]),o("noscript",["@media (scripting: none)"]),r}function bo(e){if(e.includes("=")){let[r,...o]=U(e,"="),t=o.join("=").trim();if(t[0]==="'"||t[0]==='"')return e;if(t.length>1){let l=t[t.length-1];if(t[t.length-2]===" "&&(l==="i"||l==="I"||l==="s"||l==="S"))return`${r}="${t.slice(0,-2)}" ${l}`}return`${r}="${t}"`}return e}function or(e,r){O(e,o=>{if(o.kind==="at-rule"&&o.name==="@slot")return V.ReplaceSkip(r);if(o.kind==="at-rule"&&(o.name==="@keyframes"||o.name==="@property"))return Object.assign(o,F([M(o.name,o.params,o.nodes)])),V.Skip})}function Ye(e,r){let o=0;return O(e,t=>{if(t.kind!=="at-rule"||t.name!=="@variant")return;let l=[],s=U(t.params,",");for(let[a,m]of s.entries()){let h=G("&",a===s.length-1?t.nodes:t.nodes.map(re)),f=U(m,":");for(let u=f.length-1;u>=0;--u){let p=f[u].trim();if(!p)throw new Error("Cannot use `@variant` with empty variant");let c=r.parseVariant(p);if(c===null)throw new Error(`Cannot use \`@variant\` with unknown variant: ${p}`);if(wt(h,c,r.variants)===null)throw new Error(`Cannot use \`@variant\` with variant: ${p}`)}h.selector==="&"?l.push(...h.nodes):l.push(h)}return o|=32,V.Replace(l)}),o}function yo(e,r){let o=uo(e),t=wo(e),l=new H(p=>Yr(p,u)),s=new H(p=>Array.from(Zr(p,u))),a=new H(p=>new H(c=>{let v=xo(c,u,p);try{let g=v.map(b=>b.node);Le(g,u),Ye(g,u)}catch{return[]}return v})),m=new H(p=>{for(let c of ut(p))e.markUsedVariable(c)});function h(p){let c=[];for(let v of p){let g=!0,{astNodes:b}=Se([v],u,{onInvalidCandidate(){g=!1}});r&&O(b,x=>(x.src??=r,V.Continue)),b=Te(b,u,0),c.push(g?b:[])}return c}function f(p){return h(p).map(c=>c.length>0?ae(c):null)}let u={theme:e,utilities:o,variants:t,invalidCandidates:new Set,important:!1,candidatesToCss:f,candidatesToAst:h,getClassOrder(p){return vo(this,p)},getClassList(){return ho(this)},getVariants(){return go(this)},parseCandidate(p){return s.get(p)},parseVariant(p){return l.get(p)},compileAstNodes(p,c=1){return a.get(c).get(p)},printCandidate(p){return Jr(u,p)},printVariant(p){return pt(p)},getVariantOrder(){let p=Array.from(l.values());p.sort((b,x)=>this.variants.compare(b,x));let c=new Map,v,g=0;for(let b of p)b!==null&&(v!==void 0&&this.variants.compare(v,b)!==0&&g++,c.set(b,g),v=b);return c},resolveThemeValue(p,c=!0){let v=p.lastIndexOf("/"),g=null;v!==-1&&(g=p.slice(v+1).trim(),p=p.slice(0,v).trim());let b=e.resolve(null,[p],c?1:0)??void 0;return g&&b?J(b,g):b},trackUsedVariables(p){m.get(p)},canonicalizeCandidates(p,c){return ko(this,p,c)},storage:{}};return u}var ir=["container-type","pointer-events","visibility","position","inset","inset-inline","inset-block","inset-inline-start","inset-inline-end","inset-block-start","inset-block-end","top","right","bottom","left","isolation","z-index","order","grid-column","grid-column-start","grid-column-end","grid-row","grid-row-start","grid-row-end","float","clear","--tw-container-component","margin","margin-inline","margin-block","margin-inline-start","margin-inline-end","margin-block-start","margin-block-end","margin-top","margin-right","margin-bottom","margin-left","box-sizing","display","field-sizing","aspect-ratio","height","max-height","min-height","width","max-width","min-width","flex","flex-shrink","flex-grow","flex-basis","table-layout","caption-side","border-collapse","border-spacing","transform-origin","translate","--tw-translate-x","--tw-translate-y","--tw-translate-z","scale","--tw-scale-x","--tw-scale-y","--tw-scale-z","rotate","--tw-rotate-x","--tw-rotate-y","--tw-rotate-z","--tw-skew-x","--tw-skew-y","transform","zoom","animation","cursor","touch-action","--tw-pan-x","--tw-pan-y","--tw-pinch-zoom","resize","scroll-snap-type","--tw-scroll-snap-strictness","scroll-snap-align","scroll-snap-stop","scroll-margin","scroll-margin-inline","scroll-margin-block","scroll-margin-inline-start","scroll-margin-inline-end","scroll-margin-block-start","scroll-margin-block-end","scroll-margin-top","scroll-margin-right","scroll-margin-bottom","scroll-margin-left","scroll-padding","scroll-padding-inline","scroll-padding-block","scroll-padding-inline-start","scroll-padding-inline-end","scroll-padding-block-start","scroll-padding-block-end","scroll-padding-top","scroll-padding-right","scroll-padding-bottom","scroll-padding-left","scrollbar-width","scrollbar-color","scrollbar-gutter","list-style-position","list-style-type","list-style-image","appearance","columns","break-before","break-inside","break-after","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-template-columns","grid-template-rows","flex-direction","flex-wrap","place-content","place-items","align-content","align-items","justify-content","justify-items","gap","column-gap","row-gap","--tw-space-x-reverse","--tw-space-y-reverse","divide-x-width","divide-y-width","--tw-divide-y-reverse","divide-style","divide-color","place-self","align-self","justify-self","overflow","overflow-x","overflow-y","overscroll-behavior","overscroll-behavior-x","overscroll-behavior-y","scroll-behavior","border-radius","border-start-radius","border-end-radius","border-top-radius","border-right-radius","border-bottom-radius","border-left-radius","border-start-start-radius","border-start-end-radius","border-end-end-radius","border-end-start-radius","border-top-left-radius","border-top-right-radius","border-bottom-right-radius","border-bottom-left-radius","border-width","border-inline-width","border-block-width","border-inline-start-width","border-inline-end-width","border-block-start-width","border-block-end-width","border-top-width","border-right-width","border-bottom-width","border-left-width","border-style","border-inline-style","border-block-style","border-inline-start-style","border-inline-end-style","border-block-start-style","border-block-end-style","border-top-style","border-right-style","border-bottom-style","border-left-style","border-color","border-inline-color","border-block-color","border-inline-start-color","border-inline-end-color","border-block-start-color","border-block-end-color","border-top-color","border-right-color","border-bottom-color","border-left-color","background-color","background-image","--tw-gradient-position","--tw-gradient-stops","--tw-gradient-via-stops","--tw-gradient-from","--tw-gradient-from-position","--tw-gradient-via","--tw-gradient-via-position","--tw-gradient-to","--tw-gradient-to-position","mask-image","--tw-mask-top","--tw-mask-top-from-color","--tw-mask-top-from-position","--tw-mask-top-to-color","--tw-mask-top-to-position","--tw-mask-right","--tw-mask-right-from-color","--tw-mask-right-from-position","--tw-mask-right-to-color","--tw-mask-right-to-position","--tw-mask-bottom","--tw-mask-bottom-from-color","--tw-mask-bottom-from-position","--tw-mask-bottom-to-color","--tw-mask-bottom-to-position","--tw-mask-left","--tw-mask-left-from-color","--tw-mask-left-from-position","--tw-mask-left-to-color","--tw-mask-left-to-position","--tw-mask-linear","--tw-mask-linear-position","--tw-mask-linear-from-color","--tw-mask-linear-from-position","--tw-mask-linear-to-color","--tw-mask-linear-to-position","--tw-mask-radial","--tw-mask-radial-shape","--tw-mask-radial-size","--tw-mask-radial-position","--tw-mask-radial-from-color","--tw-mask-radial-from-position","--tw-mask-radial-to-color","--tw-mask-radial-to-position","--tw-mask-conic","--tw-mask-conic-position","--tw-mask-conic-from-color","--tw-mask-conic-from-position","--tw-mask-conic-to-color","--tw-mask-conic-to-position","box-decoration-break","background-size","background-attachment","background-clip","background-position","background-repeat","background-origin","mask-composite","mask-mode","mask-type","mask-size","mask-clip","mask-position","mask-repeat","mask-origin","fill","stroke","stroke-width","object-fit","object-position","padding","padding-inline","padding-block","padding-inline-start","padding-inline-end","padding-block-start","padding-block-end","padding-top","padding-right","padding-bottom","padding-left","text-align","text-indent","vertical-align","font-family","font-feature-settings","font-size","line-height","font-weight","letter-spacing","text-wrap","overflow-wrap","word-break","text-overflow","hyphens","white-space","tab-size","color","text-transform","font-style","font-stretch","font-variant-numeric","text-decoration-line","text-decoration-color","text-decoration-style","text-decoration-thickness","text-underline-offset","-webkit-font-smoothing","placeholder-color","caret-color","accent-color","color-scheme","opacity","background-blend-mode","mix-blend-mode","box-shadow","--tw-shadow","--tw-shadow-color","--tw-ring-shadow","--tw-ring-color","--tw-inset-shadow","--tw-inset-shadow-color","--tw-inset-ring-shadow","--tw-inset-ring-color","--tw-ring-offset-width","--tw-ring-offset-color","outline","outline-width","outline-offset","outline-color","--tw-blur","--tw-brightness","--tw-contrast","--tw-drop-shadow","--tw-grayscale","--tw-hue-rotate","--tw-invert","--tw-saturate","--tw-sepia","filter","--tw-backdrop-blur","--tw-backdrop-brightness","--tw-backdrop-contrast","--tw-backdrop-grayscale","--tw-backdrop-hue-rotate","--tw-backdrop-invert","--tw-backdrop-opacity","--tw-backdrop-saturate","--tw-backdrop-sepia","backdrop-filter","transition-property","transition-behavior","transition-delay","transition-duration","transition-timing-function","will-change","contain","content","forced-color-adjust"];function Ao(e,r){let o=e.length,t=r.length,l=o=48&&a<=57&&m>=48&&m<=57){let h=s,f=s+1,u=s,p=s+1;for(a=e.charCodeAt(f);a>=48&&a<=57;)a=e.charCodeAt(++f);for(m=r.charCodeAt(p);m>=48&&m<=57;)m=r.charCodeAt(++p);let c=e.slice(h,f),v=r.slice(u,p),g=Number(c)-Number(v);if(g)return g;if(cv)return 1;continue}if(a!==m)return a-m}return e.length-r.length}function Se(e,r,{onInvalidCandidate:o,respectImportant:t}={}){let l=new Map,s=[],a=new Map;for(let f of e){if(r.invalidCandidates.has(f)){o?.(f);continue}let u=r.parseCandidate(f);if(u.length===0){o?.(f);continue}a.set(f,u)}let m=0;(t??!0)&&(m|=1);let h=r.getVariantOrder();for(let[f,u]of a){let p=!1;for(let c of u){let v=r.compileAstNodes(c,m);if(v.length!==0){p=!0;for(let{node:g,propertySort:b}of v){let x=0n;for(let T of c.variants)x|=1n<{let p=l.get(f),c=l.get(u);if(p.variants-c.variants!==0n)return Number(p.variants-c.variants);let v=0;for(;v1)return null;for(let h of a.nodes)if(h.kind!=="rule"&&h.kind!=="at-rule"||l(h,r)===null)return null;O(a.nodes,h=>{if((h.kind==="rule"||h.kind==="at-rule")&&h.nodes.length<=0)return h.nodes=e.nodes,V.Skip}),e.nodes=a.nodes;return}if(l(e,r)===null)return null}function Co(e){let r=e.options?.types??[];return r.length>1&&r.includes("any")}function Rn(e,r){if(e.kind==="arbitrary"){let a=e.value;return e.modifier&&(a=ee(a,e.modifier,r.theme)),a===null?[]:[[n(e.property,a)]]}let o=r.utilities.get(e.root)??[],t=[],l=o.filter(a=>!Co(a));for(let a of l){if(a.kind!==e.kind)continue;let m=a.compileFn(e);if(m!==void 0){if(m===null){if(a.options?.types?.length)return t;continue}t.push(m)}}if(t.length>0)return t;let s=o.filter(a=>Co(a));for(let a of s){if(a.kind!==e.kind)continue;let m=a.compileFn(e);if(m!==void 0){if(m===null){if(a.options?.types?.length)return t;continue}t.push(m)}}return t}function So(e){for(let r of e)r.kind!=="at-root"&&(r.kind==="declaration"?r.important=!0:(r.kind==="rule"||r.kind==="at-rule")&&So(r.nodes))}function Pn(e){let r=new Set,o=0,t=e.slice(),l=!1;for(;t.length>0;){let s=t.shift();if(s.kind==="declaration"){if(s.value===void 0||(o++,l))continue;if(s.property==="--tw-sort"){let m=ir.indexOf(s.value??"");if(m!==-1){r.add(m),l=!0;continue}}let a=ir.indexOf(s.property);a!==-1&&r.add(a)}else if(s.kind==="rule"||s.kind==="at-rule")for(let a of s.nodes)t.push(a)}return{order:Array.from(r).sort((s,a)=>s-a),count:o}}function Qe(e,r){let o=0,t=Y("&",e),l=new Set,s=new H(()=>new Set),a=new H(()=>new Set);O([t],(p,c)=>{if(p.kind==="at-rule"){if(p.name==="@keyframes")return O(p.nodes,v=>{if(v.kind==="at-rule"&&v.name==="@apply")throw new Error("You cannot use `@apply` inside `@keyframes`.")}),V.Skip;if(p.name==="@utility"){let v=p.params.replace(/-\*$/,"");a.get(v).add(p),O(p.nodes,g=>{if(!(g.kind!=="at-rule"||g.name!=="@apply")){l.add(p);for(let b of No(g,r))s.get(p).add(b)}});return}if(p.name==="@apply"){if(c.parent===null)return;o|=1,l.add(c.parent);for(let v of No(p,r))for(let g of c.path())l.has(g)&&s.get(g).add(v)}}});let m=new Set,h=[],f=new Set;function u(p,c=[]){if(!m.has(p)){if(f.has(p)){let v=c[(c.indexOf(p)+1)%c.length];throw p.kind==="at-rule"&&p.name==="@utility"&&v.kind==="at-rule"&&v.name==="@utility"&&O(p.nodes,g=>{if(g.kind!=="at-rule"||g.name!=="@apply")return;let b=g.params.split(/\s+/g);for(let x of b)for(let T of r.parseCandidate(x))switch(T.kind){case"arbitrary":break;case"static":case"functional":if(v.params.replace(/-\*$/,"")===T.root)throw new Error(`You cannot \`@apply\` the \`${x}\` utility here because it creates a circular dependency.`);break;default:}}),new Error(`Circular dependency detected: + +${ae([p])} +Relies on: + +${ae([v])}`)}f.add(p);for(let v of s.get(p))for(let g of a.get(v))c.push(p),u(g,c),c.pop();m.add(p),f.delete(p),h.push(p)}}for(let p of l)u(p);for(let p of h)"nodes"in p&&O(p.nodes,c=>{if(c.kind!=="at-rule"||c.name!=="@apply")return;let v=c.params.split(/(\s+)/g),g={},b=[],x=[],T=0;for(let[w,K]of v.entries())w%2===0&&(K[0]==="-"&&K[1]==="-"?x.push(K):b.push(K),g[K]=T),T+=K.length;if(x.length){if(b.length===0)return V.Skip;let w=x.join(" ");throw new Error(`You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply ${w}\` into a separate rule.`)}if(c.nodes.length>0&&b.length){let w=b.join(" ");throw new Error(`The rule \`@apply ${w}\` must not have a body.`)}{let w=Object.keys(g),K=Se(w,r,{respectImportant:!1,onInvalidCandidate:P=>{if(r.theme.prefix&&!P.startsWith(r.theme.prefix))throw new Error(`Cannot apply unprefixed utility class \`${P}\`. Did you mean \`${r.theme.prefix}:${P}\`?`);if(r.invalidCandidates.has(P))throw new Error(`Cannot apply utility class \`${P}\` because it has been explicitly disabled: https://tailwindcss.com/docs/detecting-classes-in-source-files#explicitly-excluding-classes`);let D=U(P,":");if(D.length>1){let B=D.pop();if(r.candidatesToCss([B])[0]){let W=r.candidatesToCss(D.map(te=>`${te}:[--tw-variant-check:1]`)),X=D.filter((te,i)=>W[i]===null);if(X.length>0){if(X.length===1)throw new Error(`Cannot apply utility class \`${P}\` because the ${X.map(te=>`\`${te}\``)} variant does not exist.`);{let te=new Intl.ListFormat("en",{style:"long",type:"conjunction"});throw new Error(`Cannot apply utility class \`${P}\` because the ${te.format(X.map(i=>`\`${i}\``))} variants do not exist.`)}}}}throw r.theme.size===0?new Error(`Cannot apply unknown utility class \`${P}\`. Are you using CSS modules or similar and missing \`@reference\`? https://tailwindcss.com/docs/functions-and-directives#reference-directive`):new Error(`Cannot apply unknown utility class \`${P}\``)}}),L=c.src,z=K.astNodes.map(P=>{let D=K.nodeSorting.get(P)?.candidate,B=D?g[D]:void 0;if(P=re(P),!L||!D||B===void 0)return O([P],X=>{X.src=L}),P;let W=[L[0],L[1],L[2]];return W[1]+=7+B,W[2]=W[1]+D.length,O([P],X=>{X.src=W}),P}),I=[];for(let P of z)if(P.kind==="rule")for(let D of P.nodes)I.push(D);else I.push(P);return V.Replace(I)}});return o}function*No(e,r){for(let o of e.params.split(/\s+/g))for(let t of r.parseCandidate(o))switch(t.kind){case"arbitrary":break;case"static":case"functional":yield t.root;break;default:}}async function nr(e,r,o,t=0,l=!1){let s=0,a=[];return O(e,m=>{if(m.kind==="at-rule"&&(m.name==="@import"||m.name==="@reference")){let h=On(q(m.params));if(h===null)return;m.name==="@reference"&&(h.media="reference"),s|=2;let{uri:f,layer:u,media:p,supports:c}=h;if(f.startsWith("data:")||f.startsWith("http://")||f.startsWith("https://"))return;let v=de({},[]);return a.push((async()=>{if(t>100)throw new Error(`Exceeded maximum recursion depth while resolving \`${f}\` in \`${r}\`)`);let g=await o(f,r),b=Pe(g.content,{from:l?g.path:void 0});await nr(b,g.base,o,t+1,l),v.nodes=_n(m,[de({base:g.base},b)],u,p,c)})()),V.ReplaceSkip(v)}}),a.length>0&&await Promise.all(a),s}function On(e){let r,o=null,t=null,l=null;for(let s=0;s/g,"1")),t[0]==="opacity"&&(typeof l=="number"||typeof l=="string")){let a=typeof l=="string"?parseFloat(l):l;a>=0&&a<=1&&(l=a*100+"%")}let s=yt(t);s&&e.theme.add(`--${s}`,""+l,7)}if(Object.hasOwn(r,"fontFamily")){let t=5;{let l=ze(r.fontFamily.sans);l&&e.theme.hasDefault("--font-sans")&&(e.theme.add("--default-font-family",l,t),e.theme.add("--default-font-feature-settings",ze(r.fontFamily.sans,"fontFeatureSettings")??"normal",t),e.theme.add("--default-font-variation-settings",ze(r.fontFamily.sans,"fontVariationSettings")??"normal",t))}{let l=ze(r.fontFamily.mono);l&&e.theme.hasDefault("--font-mono")&&(e.theme.add("--default-mono-font-family",l,t),e.theme.add("--default-mono-font-feature-settings",ze(r.fontFamily.mono,"fontFeatureSettings")??"normal",t),e.theme.add("--default-mono-font-variation-settings",ze(r.fontFamily.mono,"fontVariationSettings")??"normal",t))}}return r}function Ln(e){let r=[];return $o(e,[],(o,t)=>{if(Dn(o))return r.push([t,o]),1;if(In(o)){r.push([t,o[0]]);for(let l of Reflect.ownKeys(o[1]))r.push([[...t,`-${l}`],o[1][l]]);return 1}if(Array.isArray(o)&&o.every(l=>typeof l=="string"))return t[0]==="fontSize"?(r.push([t,o[0]]),o.length>=2&&r.push([[...t,"-line-height"],o[1]])):r.push([t,o.join(", ")]),1}),r}var Un={borderWidth:"border-width",outlineWidth:"outline-width",ringColor:"ring-color",ringWidth:"ring-width",transitionDuration:"transition-duration",transitionTimingFunction:"transition-timing-function"},zn={animation:"animate",aspectRatio:"aspect",borderRadius:"radius",boxShadow:"shadow",colors:"color",containers:"container",fontFamily:"font",fontSize:"text",letterSpacing:"tracking",lineHeight:"leading",maxWidth:"container",screens:"breakpoint",transitionTimingFunction:"ease"},Kn=/^[a-zA-Z0-9-_%/.]+$/;function yt(e){let r=Un[e[0]];if(r&&e[1]==="DEFAULT")return`default-${r}`;if(e[0]==="container")return null;for(let t of e)if(!Kn.test(t))return null;let o=zn[e[0]];return o&&(e=e.slice(),e[0]=o),e.map((t,l,s)=>t==="1"&&l!==s.length-1?"":t).map((t,l)=>(t=t.replaceAll(".","_"),(l===0||t.startsWith("-")||t==="lineHeight")&&(t=t.replace(/([a-z])([A-Z])/g,(a,m,h)=>`${m}-${h.toLowerCase()}`)),t)).filter((t,l)=>t!=="DEFAULT"||l!==e.length-1).join("-")}function Dn(e){return typeof e=="number"||typeof e=="string"}function In(e){if(!Array.isArray(e)||e.length!==2||typeof e[0]!="string"&&typeof e[0]!="number"||e[1]===void 0||e[1]===null||typeof e[1]!="object")return!1;for(let r of Reflect.ownKeys(e[1]))if(typeof r!="string"||typeof e[1][r]!="string"&&typeof e[1][r]!="number")return!1;return!0}function $o(e,r=[],o){for(let t of Reflect.ownKeys(e)){let l=e[t];if(l==null)continue;let s=[...r,t],a=o(l,s)??0;if(a!==1){if(a===2)return 2;if(!(!Array.isArray(l)&&typeof l!="object")&&$o(l,s,o)===2)return 2}}}function xt(e){let r=[];for(let o of U(e,".")){if(!o.includes("[")){r.push(o);continue}let t=0;for(;;){let l=o.indexOf("[",t),s=o.indexOf("]",l);if(l===-1||s===-1)break;l>t&&r.push(o.slice(t,l)),r.push(o.slice(l+1,s)),t=s+1}t<=o.length-1&&r.push(o.slice(t))}return r}function Ke(e){if(Object.prototype.toString.call(e)!=="[object Object]")return!1;let r=Object.getPrototypeOf(e);return r===null||Object.getPrototypeOf(r)===null}function Je(e,r,o,t=[]){for(let l of r)if(l!=null)for(let s of Reflect.ownKeys(l)){t.push(s);let a=o(e[s],l[s],t);a!==void 0?e[s]=a:!Ke(e[s])||!Ke(l[s])?e[s]=l[s]:e[s]=Je({},[e[s],l[s]],o,t),t.pop()}return e}function At(e,r,o){return function(l,s){let a=l.lastIndexOf("/"),m=null;a!==-1&&(m=l.slice(a+1).trim(),l=l.slice(0,a).trim());let h=(()=>{let f=xt(l),[u,p]=jn(e.theme,f),c=o(Eo(r()??{},f)??null);if(typeof c=="string"&&(c=c.replace("","1")),typeof u!="object")return typeof p!="object"&&p&4?c??u:u;if(c!==null&&typeof c=="object"&&!Array.isArray(c)){let v=Je({},[c],(g,b)=>b);if(u===null&&Object.hasOwn(c,"__CSS_VALUES__")){let g={};for(let b in c.__CSS_VALUES__)g[b]=c[b],delete v[b];u=g}for(let g in u)g!=="__CSS_VALUES__"&&(c?.__CSS_VALUES__?.[g]&4&&Eo(v,g.split("-"))!==void 0||(v[ke(g)]=u[g]));return v}if(Array.isArray(u)&&Array.isArray(p)&&Array.isArray(c)){let v=u[0],g=u[1];p[0]&4&&(v=c[0]??v);for(let b of Object.keys(g))p[1][b]&4&&(g[b]=c[1][b]??g[b]);return[v,g]}return u!==null&&typeof u=="object"&&!Array.isArray(u)&&"DEFAULT"in u?u.DEFAULT:u??c})();return m&&typeof h=="string"&&(h=J(h,m)),h??s}}function jn(e,r){if(r.length===1&&r[0].startsWith("--"))return[e.get([r[0]]),e.getOptions(r[0])];let o=yt(r),t=new Map,l=new H(()=>new Map),s=e.namespace(`--${o}`);if(s.size===0)return[null,0];let a=new Map;for(let[u,p]of s){if(!u||!u.includes("--")){t.set(u,p),a.set(u,e.getOptions(u?`--${o}-${u}`:`--${o}`));continue}let c=u.indexOf("--"),v=u.slice(0,c),g=u.slice(c+2);g=g.replace(/-([a-z])/g,(b,x)=>x.toUpperCase()),l.get(v===""?null:v).set(g,[p,e.getOptions(`--${o}${u}`)])}let m=e.getOptions(`--${o}`);for(let[u,p]of l){let c=t.get(u);if(typeof c!="string")continue;let v={},g={};for(let[b,[x,T]]of p)v[b]=x,g[b]=T;t.set(u,[c,v]),a.set(u,[m,g])}let h={},f={};for(let[u,p]of t)Vo(h,[u??"DEFAULT"],p);for(let[u,p]of a)Vo(f,[u??"DEFAULT"],p);return r[r.length-1]==="DEFAULT"?[h?.DEFAULT??null,f.DEFAULT??0]:"DEFAULT"in h&&Object.keys(h).length===1?[h.DEFAULT,f.DEFAULT??0]:(h.__CSS_VALUES__=f,[h,f])}function Eo(e,r){for(let o=0;o{u.src=s}),r.push(f)},addVariant(m,h){if(!bt.test(m))throw new Error(`\`addVariant('${m}')\` defines an invalid variant name. Variants should only contain alphanumeric, dashes, or underscore characters and start with a lowercase letter or number.`);if(typeof h=="string"){if(h.includes(":merge("))return}else if(Array.isArray(h)){if(h.some(u=>u.includes(":merge(")))return}else if(typeof h=="object"){let u=function(p,c){return Object.entries(p).some(([v,g])=>v.includes(c)||typeof g=="object"&&u(g,c))};var f=u;if(u(h,":merge("))return}typeof h=="string"||Array.isArray(h)?e.variants.static(m,u=>{u.nodes=Po(h,u.nodes)},{compounds:Ve(typeof h=="string"?[h]:h)}):typeof h=="object"&&e.variants.fromAst(m,me(h),e)},matchVariant(m,h,f){function u(c,v,g){let b=h(c,{modifier:v?.value??null});return Po(b,g)}try{let c=h("a",{modifier:null});if(typeof c=="string"&&c.includes(":merge("))return;if(Array.isArray(c)&&c.some(v=>v.includes(":merge(")))return}catch{}let p=Object.keys(f?.values??{});e.variants.group(()=>{e.variants.functional(m,(c,v)=>{if(!v.value){if(f?.values&&"DEFAULT"in f.values){c.nodes=u(f.values.DEFAULT,v.modifier,c.nodes);return}return null}if(v.value.kind==="arbitrary")c.nodes=u(v.value.value,v.modifier,c.nodes);else if(v.value.kind==="named"&&f?.values){if(!Object.hasOwn(f.values,v.value.value))return null;let g=f.values[v.value.value];if(typeof g!="string")return null;c.nodes=u(g,v.modifier,c.nodes)}else return null})},(c,v)=>{if(c.kind!=="functional"||v.kind!=="functional")return 0;let g=c.value?c.value.value:"DEFAULT",b=v.value?v.value.value:"DEFAULT",x=(f?.values&&Object.hasOwn(f.values,g)?f.values[g]:void 0)??g,T=(f?.values&&Object.hasOwn(f.values,b)?f.values[b]:void 0)??b;if(f&&typeof f.sort=="function")return f.sort({value:x,modifier:c.modifier?.value??null},{value:T,modifier:v.modifier?.value??null});let A=p.indexOf(g),w=p.indexOf(b);return A=A===-1?p.length:A,w=w===-1?p.length:w,A!==w?A-w:xObject.keys(f?.values??{}).filter(c=>c!=="DEFAULT"))},addUtilities(m){m=Array.isArray(m)?m:[m];let h=m.flatMap(u=>Object.entries(u));h=h.flatMap(([u,p])=>U(u,",").map(c=>[c.trim(),p]));let f=new H(()=>[]);for(let[u,p]of h){if(u.startsWith("@keyframes ")){if(!l){let g=Y(u,me(p));O([g],b=>{b.src=s}),r.push(g)}continue}let c=ge(u),v=!1;if(O(c,g=>{if(g.kind==="selector"&&g.value[0]==="."&&Ro.test(g.value.slice(1))){let b=g.value;g.value="&";let x=pe(c),T=b.slice(1),A=x==="&"?me(p):[Y(x,me(p))];f.get(T).push(...A),v=!0,g.value=b;return}if(g.kind==="function"&&(g.value===":not"||g.value===":nth-child"||g.value===":nth-last-child"))return V.Skip}),!v)throw new Error(`\`addUtilities({ '${u}' : \u2026 })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`)}for(let[u,p]of f)e.theme.prefix&&O(p,c=>{if(c.kind==="rule"){let v=ge(c.selector);O(v,g=>{g.kind==="selector"&&g.value[0]==="."&&(g.value=`.${e.theme.prefix}\\:${g.value.slice(1)}`)}),c.selector=pe(v)}}),e.utilities.static(u,c=>{let v=p.map(re);return Oo(v,u,c.raw),t.current|=Qe(v,e),v})},matchUtilities(m,h){let f=h?.type?Array.isArray(h?.type)?h.type:[h.type]:["any"];for(let[p,c]of Object.entries(m)){let v=function({negative:g}){return b=>{if(b.value?.kind==="arbitrary"&&f.length>0&&!f.includes("any")&&(b.value.dataType&&!f.includes(b.value.dataType)||!b.value.dataType&&!Q(b.value.value,f)))return;let x=f.includes("color"),T=null,A=!1;{let L=h?.values??{};x&&(L=Object.assign({inherit:"inherit",transparent:"transparent",current:"currentcolor"},L)),b.value?b.value.kind==="arbitrary"?T=b.value.value:b.value.fraction&&Object.hasOwn(L,b.value.fraction)?(T=L[b.value.fraction],A=!0):Object.hasOwn(L,b.value.value)?T=L[b.value.value]:L.__BARE_VALUE__&&(T=L.__BARE_VALUE__(b.value)??null,A=(b.value.fraction!==null&&T?.includes("/"))??!1):T=L.DEFAULT??null}if(T===null)return;let w;{let L=h?.modifiers??null;b.modifier?L==="any"||b.modifier.kind==="arbitrary"?w=b.modifier.value:L&&Object.hasOwn(L,b.modifier.value)?w=L[b.modifier.value]:x&&!Number.isNaN(Number(b.modifier.value))?w=`${b.modifier.value}%`:w=null:w=null}if(b.modifier&&w===null&&!A)return b.value?.kind==="arbitrary"?null:void 0;x&&w!==null&&(T=J(T,w)),g&&(T=`calc(${T} * -1)`);let K=me(c(T,{modifier:w}));return Oo(K,p,b.raw),t.current|=Qe(K,e),K}};var u=v;if(!Ro.test(p))throw new Error(`\`matchUtilities({ '${p}' : \u2026 })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`);h?.supportsNegativeValues&&e.utilities.functional(`-${p}`,v({negative:!0}),{types:f}),e.utilities.functional(p,v({negative:!1}),{types:f}),e.utilities.suggest(p,()=>{let g=h?.values??{},b=new Set(Object.keys(g));b.delete("__BARE_VALUE__"),b.delete("__CSS_VALUES__"),b.has("DEFAULT")&&(b.delete("DEFAULT"),b.add(null));let x=h?.modifiers??{},T=x==="any"?[]:Object.keys(x);return[{supportsNegative:h?.supportsNegativeValues??!1,values:Array.from(b),modifiers:T}]})}},addComponents(m,h){this.addUtilities(m,h)},matchComponents(m,h){this.matchUtilities(m,h)},theme:At(e,()=>o.theme??{},m=>m),prefix(m){return m},config(m,h){let f=o;if(!m)return f;let u=xt(m);for(let p=0;pObject.entries(t));for(let[t,l]of o)if(l!=null&&l!==!1)if(typeof l!="object"){if(!t.startsWith("--")){if(l==="@slot"){r.push(Y(t,[M("@slot")]));continue}t=t.replace(/([A-Z])/g,"-$1").toLowerCase()}r.push(n(t,String(l)))}else if(Array.isArray(l))for(let s of l)typeof s=="string"?r.push(n(t,s)):r.push(Y(t,me(s)));else r.push(Y(t,me(l)));return r}function Po(e,r){return(typeof e=="string"?[e]:e).flatMap(t=>{if(t.trim().endsWith("}")){let l=t.replace("}","{@slot}}"),s=Pe(l);return or(s,r),s}else return Y(t,r)})}function Oo(e,r,o){O(e,t=>{if(t.kind==="rule"){let l=ge(t.selector);O(l,s=>{s.kind==="selector"&&s.value===`.${r}`&&(s.value=`.${xe(o)}`)}),t.selector=pe(l)}})}function _o(e,r){for(let o of Fn(r))e.theme.addKeyframes(o)}function Fn(e){let r=[];if("keyframes"in e.theme)for(let[o,t]of Object.entries(e.theme.keyframes))r.push(M("@keyframes",o,me(t)));return r}var Ct={inherit:"inherit",current:"currentcolor",transparent:"transparent",black:"#000",white:"#fff",slate:{50:"oklch(98.4% 0.003 247.858)",100:"oklch(96.8% 0.007 247.896)",200:"oklch(92.9% 0.013 255.508)",300:"oklch(86.9% 0.022 252.894)",400:"oklch(70.4% 0.04 256.788)",500:"oklch(55.4% 0.046 257.417)",600:"oklch(44.6% 0.043 257.281)",700:"oklch(37.2% 0.044 257.287)",800:"oklch(27.9% 0.041 260.031)",900:"oklch(20.8% 0.042 265.755)",950:"oklch(12.9% 0.042 264.695)"},gray:{50:"oklch(98.5% 0.002 247.839)",100:"oklch(96.7% 0.003 264.542)",200:"oklch(92.8% 0.006 264.531)",300:"oklch(87.2% 0.01 258.338)",400:"oklch(70.7% 0.022 261.325)",500:"oklch(55.1% 0.027 264.364)",600:"oklch(44.6% 0.03 256.802)",700:"oklch(37.3% 0.034 259.733)",800:"oklch(27.8% 0.033 256.848)",900:"oklch(21% 0.034 264.665)",950:"oklch(13% 0.028 261.692)"},zinc:{50:"oklch(98.5% 0 none)",100:"oklch(96.7% 0.001 286.375)",200:"oklch(92% 0.004 286.32)",300:"oklch(87.1% 0.006 286.286)",400:"oklch(70.5% 0.015 286.067)",500:"oklch(55.2% 0.016 285.938)",600:"oklch(44.2% 0.017 285.786)",700:"oklch(37% 0.013 285.805)",800:"oklch(27.4% 0.006 286.033)",900:"oklch(21% 0.006 285.885)",950:"oklch(14.1% 0.005 285.823)"},neutral:{50:"oklch(98.5% 0 none)",100:"oklch(97% 0 none)",200:"oklch(92.2% 0 none)",300:"oklch(87% 0 none)",400:"oklch(70.8% 0 none)",500:"oklch(55.6% 0 none)",600:"oklch(43.9% 0 none)",700:"oklch(37.1% 0 none)",800:"oklch(26.9% 0 none)",900:"oklch(20.5% 0 none)",950:"oklch(14.5% 0 none)"},stone:{50:"oklch(98.5% 0.001 106.423)",100:"oklch(97% 0.001 106.424)",200:"oklch(92.3% 0.003 48.717)",300:"oklch(86.9% 0.005 56.366)",400:"oklch(70.9% 0.01 56.259)",500:"oklch(55.3% 0.013 58.071)",600:"oklch(44.4% 0.011 73.639)",700:"oklch(37.4% 0.01 67.558)",800:"oklch(26.8% 0.007 34.298)",900:"oklch(21.6% 0.006 56.043)",950:"oklch(14.7% 0.004 49.25)"},mauve:{50:"oklch(98.5% 0 none)",100:"oklch(96% 0.003 325.6)",200:"oklch(92.2% 0.005 325.62)",300:"oklch(86.5% 0.012 325.68)",400:"oklch(71.1% 0.019 323.02)",500:"oklch(54.2% 0.034 322.5)",600:"oklch(43.5% 0.029 321.78)",700:"oklch(36.4% 0.029 323.89)",800:"oklch(26.3% 0.024 320.12)",900:"oklch(21.2% 0.019 322.12)",950:"oklch(14.5% 0.008 326)"},olive:{50:"oklch(98.8% 0.003 106.5)",100:"oklch(96.6% 0.005 106.5)",200:"oklch(93% 0.007 106.5)",300:"oklch(88% 0.011 106.6)",400:"oklch(73.7% 0.021 106.9)",500:"oklch(58% 0.031 107.3)",600:"oklch(46.6% 0.025 107.3)",700:"oklch(39.4% 0.023 107.4)",800:"oklch(28.6% 0.016 107.4)",900:"oklch(22.8% 0.013 107.4)",950:"oklch(15.3% 0.006 107.1)"},mist:{50:"oklch(98.7% 0.002 197.1)",100:"oklch(96.3% 0.002 197.1)",200:"oklch(92.5% 0.005 214.3)",300:"oklch(87.2% 0.007 219.6)",400:"oklch(72.3% 0.014 214.4)",500:"oklch(56% 0.021 213.5)",600:"oklch(45% 0.017 213.2)",700:"oklch(37.8% 0.015 216)",800:"oklch(27.5% 0.011 216.9)",900:"oklch(21.8% 0.008 223.9)",950:"oklch(14.8% 0.004 228.8)"},taupe:{50:"oklch(98.6% 0.002 67.8)",100:"oklch(96% 0.002 17.2)",200:"oklch(92.2% 0.005 34.3)",300:"oklch(86.8% 0.007 39.5)",400:"oklch(71.4% 0.014 41.2)",500:"oklch(54.7% 0.021 43.1)",600:"oklch(43.8% 0.017 39.3)",700:"oklch(36.7% 0.016 35.7)",800:"oklch(26.8% 0.011 36.5)",900:"oklch(21.4% 0.009 43.1)",950:"oklch(14.7% 0.004 49.3)"},red:{50:"oklch(97.1% 0.013 17.38)",100:"oklch(93.6% 0.032 17.717)",200:"oklch(88.5% 0.062 18.334)",300:"oklch(80.8% 0.114 19.571)",400:"oklch(70.4% 0.191 22.216)",500:"oklch(63.7% 0.237 25.331)",600:"oklch(57.7% 0.245 27.325)",700:"oklch(50.5% 0.213 27.518)",800:"oklch(44.4% 0.177 26.899)",900:"oklch(39.6% 0.141 25.723)",950:"oklch(25.8% 0.092 26.042)"},orange:{50:"oklch(98% 0.016 73.684)",100:"oklch(95.4% 0.038 75.164)",200:"oklch(90.1% 0.076 70.697)",300:"oklch(83.7% 0.128 66.29)",400:"oklch(75% 0.183 55.934)",500:"oklch(70.5% 0.213 47.604)",600:"oklch(64.6% 0.222 41.116)",700:"oklch(55.3% 0.195 38.402)",800:"oklch(47% 0.157 37.304)",900:"oklch(40.8% 0.123 38.172)",950:"oklch(26.6% 0.079 36.259)"},amber:{50:"oklch(98.7% 0.022 95.277)",100:"oklch(96.2% 0.059 95.617)",200:"oklch(92.4% 0.12 95.746)",300:"oklch(87.9% 0.169 91.605)",400:"oklch(82.8% 0.189 84.429)",500:"oklch(76.9% 0.188 70.08)",600:"oklch(66.6% 0.179 58.318)",700:"oklch(55.5% 0.163 48.998)",800:"oklch(47.3% 0.137 46.201)",900:"oklch(41.4% 0.112 45.904)",950:"oklch(27.9% 0.077 45.635)"},yellow:{50:"oklch(98.7% 0.026 102.212)",100:"oklch(97.3% 0.071 103.193)",200:"oklch(94.5% 0.129 101.54)",300:"oklch(90.5% 0.182 98.111)",400:"oklch(85.2% 0.199 91.936)",500:"oklch(79.5% 0.184 86.047)",600:"oklch(68.1% 0.162 75.834)",700:"oklch(55.4% 0.135 66.442)",800:"oklch(47.6% 0.114 61.907)",900:"oklch(42.1% 0.095 57.708)",950:"oklch(28.6% 0.066 53.813)"},lime:{50:"oklch(98.6% 0.031 120.757)",100:"oklch(96.7% 0.067 122.328)",200:"oklch(93.8% 0.127 124.321)",300:"oklch(89.7% 0.196 126.665)",400:"oklch(84.1% 0.238 128.85)",500:"oklch(76.8% 0.233 130.85)",600:"oklch(64.8% 0.2 131.684)",700:"oklch(53.2% 0.157 131.589)",800:"oklch(45.3% 0.124 130.933)",900:"oklch(40.5% 0.101 131.063)",950:"oklch(27.4% 0.072 132.109)"},green:{50:"oklch(98.2% 0.018 155.826)",100:"oklch(96.2% 0.044 156.743)",200:"oklch(92.5% 0.084 155.995)",300:"oklch(87.1% 0.15 154.449)",400:"oklch(79.2% 0.209 151.711)",500:"oklch(72.3% 0.219 149.579)",600:"oklch(62.7% 0.194 149.214)",700:"oklch(52.7% 0.154 150.069)",800:"oklch(44.8% 0.119 151.328)",900:"oklch(39.3% 0.095 152.535)",950:"oklch(26.6% 0.065 152.934)"},emerald:{50:"oklch(97.9% 0.021 166.113)",100:"oklch(95% 0.052 163.051)",200:"oklch(90.5% 0.093 164.15)",300:"oklch(84.5% 0.143 164.978)",400:"oklch(76.5% 0.177 163.223)",500:"oklch(69.6% 0.17 162.48)",600:"oklch(59.6% 0.145 163.225)",700:"oklch(50.8% 0.118 165.612)",800:"oklch(43.2% 0.095 166.913)",900:"oklch(37.8% 0.077 168.94)",950:"oklch(26.2% 0.051 172.552)"},teal:{50:"oklch(98.4% 0.014 180.72)",100:"oklch(95.3% 0.051 180.801)",200:"oklch(91% 0.096 180.426)",300:"oklch(85.5% 0.138 181.071)",400:"oklch(77.7% 0.152 181.912)",500:"oklch(70.4% 0.14 182.503)",600:"oklch(60% 0.118 184.704)",700:"oklch(51.1% 0.096 186.391)",800:"oklch(43.7% 0.078 188.216)",900:"oklch(38.6% 0.063 188.416)",950:"oklch(27.7% 0.046 192.524)"},cyan:{50:"oklch(98.4% 0.019 200.873)",100:"oklch(95.6% 0.045 203.388)",200:"oklch(91.7% 0.08 205.041)",300:"oklch(86.5% 0.127 207.078)",400:"oklch(78.9% 0.154 211.53)",500:"oklch(71.5% 0.143 215.221)",600:"oklch(60.9% 0.126 221.723)",700:"oklch(52% 0.105 223.128)",800:"oklch(45% 0.085 224.283)",900:"oklch(39.8% 0.07 227.392)",950:"oklch(30.2% 0.056 229.695)"},sky:{50:"oklch(97.7% 0.013 236.62)",100:"oklch(95.1% 0.026 236.824)",200:"oklch(90.1% 0.058 230.902)",300:"oklch(82.8% 0.111 230.318)",400:"oklch(74.6% 0.16 232.661)",500:"oklch(68.5% 0.169 237.323)",600:"oklch(58.8% 0.158 241.966)",700:"oklch(50% 0.134 242.749)",800:"oklch(44.3% 0.11 240.79)",900:"oklch(39.1% 0.09 240.876)",950:"oklch(29.3% 0.066 243.157)"},blue:{50:"oklch(97% 0.014 254.604)",100:"oklch(93.2% 0.032 255.585)",200:"oklch(88.2% 0.059 254.128)",300:"oklch(80.9% 0.105 251.813)",400:"oklch(70.7% 0.165 254.624)",500:"oklch(62.3% 0.214 259.815)",600:"oklch(54.6% 0.245 262.881)",700:"oklch(48.8% 0.243 264.376)",800:"oklch(42.4% 0.199 265.638)",900:"oklch(37.9% 0.146 265.522)",950:"oklch(28.2% 0.091 267.935)"},indigo:{50:"oklch(96.2% 0.018 272.314)",100:"oklch(93% 0.034 272.788)",200:"oklch(87% 0.065 274.039)",300:"oklch(78.5% 0.115 274.713)",400:"oklch(67.3% 0.182 276.935)",500:"oklch(58.5% 0.233 277.117)",600:"oklch(51.1% 0.262 276.966)",700:"oklch(45.7% 0.24 277.023)",800:"oklch(39.8% 0.195 277.366)",900:"oklch(35.9% 0.144 278.697)",950:"oklch(25.7% 0.09 281.288)"},violet:{50:"oklch(96.9% 0.016 293.756)",100:"oklch(94.3% 0.029 294.588)",200:"oklch(89.4% 0.057 293.283)",300:"oklch(81.1% 0.111 293.571)",400:"oklch(70.2% 0.183 293.541)",500:"oklch(60.6% 0.25 292.717)",600:"oklch(54.1% 0.281 293.009)",700:"oklch(49.1% 0.27 292.581)",800:"oklch(43.2% 0.232 292.759)",900:"oklch(38% 0.189 293.745)",950:"oklch(28.3% 0.141 291.089)"},purple:{50:"oklch(97.7% 0.014 308.299)",100:"oklch(94.6% 0.033 307.174)",200:"oklch(90.2% 0.063 306.703)",300:"oklch(82.7% 0.119 306.383)",400:"oklch(71.4% 0.203 305.504)",500:"oklch(62.7% 0.265 303.9)",600:"oklch(55.8% 0.288 302.321)",700:"oklch(49.6% 0.265 301.924)",800:"oklch(43.8% 0.218 303.724)",900:"oklch(38.1% 0.176 304.987)",950:"oklch(29.1% 0.149 302.717)"},fuchsia:{50:"oklch(97.7% 0.017 320.058)",100:"oklch(95.2% 0.037 318.852)",200:"oklch(90.3% 0.076 319.62)",300:"oklch(83.3% 0.145 321.434)",400:"oklch(74% 0.238 322.16)",500:"oklch(66.7% 0.295 322.15)",600:"oklch(59.1% 0.293 322.896)",700:"oklch(51.8% 0.253 323.949)",800:"oklch(45.2% 0.211 324.591)",900:"oklch(40.1% 0.17 325.612)",950:"oklch(29.3% 0.136 325.661)"},pink:{50:"oklch(97.1% 0.014 343.198)",100:"oklch(94.8% 0.028 342.258)",200:"oklch(89.9% 0.061 343.231)",300:"oklch(82.3% 0.12 346.018)",400:"oklch(71.8% 0.202 349.761)",500:"oklch(65.6% 0.241 354.308)",600:"oklch(59.2% 0.249 0.584)",700:"oklch(52.5% 0.223 3.958)",800:"oklch(45.9% 0.187 3.815)",900:"oklch(40.8% 0.153 2.432)",950:"oklch(28.4% 0.109 3.907)"},rose:{50:"oklch(96.9% 0.015 12.422)",100:"oklch(94.1% 0.03 12.58)",200:"oklch(89.2% 0.058 10.001)",300:"oklch(81% 0.117 11.638)",400:"oklch(71.2% 0.194 13.428)",500:"oklch(64.5% 0.246 16.439)",600:"oklch(58.6% 0.253 17.585)",700:"oklch(51.4% 0.222 16.935)",800:"oklch(45.5% 0.188 13.697)",900:"oklch(41% 0.159 10.272)",950:"oklch(27.1% 0.105 12.094)"}};function Re(e){return{__BARE_VALUE__:e}}var ce=Re(e=>{if(R(e.value))return e.value}),ne=Re(e=>{if(R(e.value))return`${e.value}%`}),Ne=Re(e=>{if(R(e.value))return`${e.value}px`}),Lo=Re(e=>{if(R(e.value))return`${e.value}ms`}),St=Re(e=>{if(R(e.value))return`${e.value}deg`}),Mn=Re(e=>{if(e.fraction===null)return;let[r,o]=U(e.fraction,"/");if(!(!R(r)||!R(o)))return e.fraction}),Uo=Re(e=>{if(R(Number(e.value)))return`repeat(${e.value}, minmax(0, 1fr))`}),zo={accentColor:({theme:e})=>e("colors"),animation:{none:"none",spin:"spin 1s linear infinite",ping:"ping 1s cubic-bezier(0, 0, 0.2, 1) infinite",pulse:"pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",bounce:"bounce 1s infinite"},aria:{busy:'busy="true"',checked:'checked="true"',disabled:'disabled="true"',expanded:'expanded="true"',hidden:'hidden="true"',pressed:'pressed="true"',readonly:'readonly="true"',required:'required="true"',selected:'selected="true"'},aspectRatio:{auto:"auto",square:"1 / 1",video:"16 / 9",...Mn},backdropBlur:({theme:e})=>e("blur"),backdropBrightness:({theme:e})=>({...e("brightness"),...ne}),backdropContrast:({theme:e})=>({...e("contrast"),...ne}),backdropGrayscale:({theme:e})=>({...e("grayscale"),...ne}),backdropHueRotate:({theme:e})=>({...e("hueRotate"),...St}),backdropInvert:({theme:e})=>({...e("invert"),...ne}),backdropOpacity:({theme:e})=>({...e("opacity"),...ne}),backdropSaturate:({theme:e})=>({...e("saturate"),...ne}),backdropSepia:({theme:e})=>({...e("sepia"),...ne}),backgroundColor:({theme:e})=>e("colors"),backgroundImage:{none:"none","gradient-to-t":"linear-gradient(to top, var(--tw-gradient-stops))","gradient-to-tr":"linear-gradient(to top right, var(--tw-gradient-stops))","gradient-to-r":"linear-gradient(to right, var(--tw-gradient-stops))","gradient-to-br":"linear-gradient(to bottom right, var(--tw-gradient-stops))","gradient-to-b":"linear-gradient(to bottom, var(--tw-gradient-stops))","gradient-to-bl":"linear-gradient(to bottom left, var(--tw-gradient-stops))","gradient-to-l":"linear-gradient(to left, var(--tw-gradient-stops))","gradient-to-tl":"linear-gradient(to top left, var(--tw-gradient-stops))"},backgroundOpacity:({theme:e})=>e("opacity"),backgroundPosition:{bottom:"bottom",center:"center",left:"left","left-bottom":"left bottom","left-top":"left top",right:"right","right-bottom":"right bottom","right-top":"right top",top:"top"},backgroundSize:{auto:"auto",cover:"cover",contain:"contain"},blur:{0:"0",none:"",sm:"4px",DEFAULT:"8px",md:"12px",lg:"16px",xl:"24px","2xl":"40px","3xl":"64px"},borderColor:({theme:e})=>({DEFAULT:"currentcolor",...e("colors")}),borderOpacity:({theme:e})=>e("opacity"),borderRadius:{none:"0px",sm:"0.125rem",DEFAULT:"0.25rem",md:"0.375rem",lg:"0.5rem",xl:"0.75rem","2xl":"1rem","3xl":"1.5rem",full:"9999px"},borderSpacing:({theme:e})=>e("spacing"),borderWidth:{DEFAULT:"1px",0:"0px",2:"2px",4:"4px",8:"8px",...Ne},boxShadow:{sm:"0 1px 2px 0 rgb(0 0 0 / 0.05)",DEFAULT:"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",md:"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",lg:"0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",xl:"0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)","2xl":"0 25px 50px -12px rgb(0 0 0 / 0.25)",inner:"inset 0 2px 4px 0 rgb(0 0 0 / 0.05)",none:"none"},boxShadowColor:({theme:e})=>e("colors"),brightness:{0:"0",50:".5",75:".75",90:".9",95:".95",100:"1",105:"1.05",110:"1.1",125:"1.25",150:"1.5",200:"2",...ne},caretColor:({theme:e})=>e("colors"),colors:()=>({...Ct}),columns:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12","3xs":"16rem","2xs":"18rem",xs:"20rem",sm:"24rem",md:"28rem",lg:"32rem",xl:"36rem","2xl":"42rem","3xl":"48rem","4xl":"56rem","5xl":"64rem","6xl":"72rem","7xl":"80rem",...ce},container:{},content:{none:"none"},contrast:{0:"0",50:".5",75:".75",100:"1",125:"1.25",150:"1.5",200:"2",...ne},cursor:{auto:"auto",default:"default",pointer:"pointer",wait:"wait",text:"text",move:"move",help:"help","not-allowed":"not-allowed",none:"none","context-menu":"context-menu",progress:"progress",cell:"cell",crosshair:"crosshair","vertical-text":"vertical-text",alias:"alias",copy:"copy","no-drop":"no-drop",grab:"grab",grabbing:"grabbing","all-scroll":"all-scroll","col-resize":"col-resize","row-resize":"row-resize","n-resize":"n-resize","e-resize":"e-resize","s-resize":"s-resize","w-resize":"w-resize","ne-resize":"ne-resize","nw-resize":"nw-resize","se-resize":"se-resize","sw-resize":"sw-resize","ew-resize":"ew-resize","ns-resize":"ns-resize","nesw-resize":"nesw-resize","nwse-resize":"nwse-resize","zoom-in":"zoom-in","zoom-out":"zoom-out"},divideColor:({theme:e})=>e("borderColor"),divideOpacity:({theme:e})=>e("borderOpacity"),divideWidth:({theme:e})=>({...e("borderWidth"),...Ne}),dropShadow:{sm:"0 1px 1px rgb(0 0 0 / 0.05)",DEFAULT:["0 1px 2px rgb(0 0 0 / 0.1)","0 1px 1px rgb(0 0 0 / 0.06)"],md:["0 4px 3px rgb(0 0 0 / 0.07)","0 2px 2px rgb(0 0 0 / 0.06)"],lg:["0 10px 8px rgb(0 0 0 / 0.04)","0 4px 3px rgb(0 0 0 / 0.1)"],xl:["0 20px 13px rgb(0 0 0 / 0.03)","0 8px 5px rgb(0 0 0 / 0.08)"],"2xl":"0 25px 25px rgb(0 0 0 / 0.15)",none:"0 0 #0000"},fill:({theme:e})=>e("colors"),flex:{1:"1 1 0%",auto:"1 1 auto",initial:"0 1 auto",none:"none"},flexBasis:({theme:e})=>({auto:"auto","1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%",...e("spacing")}),flexGrow:{0:"0",DEFAULT:"1",...ce},flexShrink:{0:"0",DEFAULT:"1",...ce},fontFamily:{sans:["-apple-system","BlinkMacSystemFont",'"Segoe UI"',"Roboto",'"Helvetica Neue"','"Noto Sans"',"Arial","sans-serif",'"Apple Color Emoji"','"Segoe UI Emoji"','"Segoe UI Symbol"','"Noto Color Emoji"'],serif:["ui-serif","Georgia","Cambria",'"Times New Roman"',"Times","serif"],mono:["ui-monospace","SFMono-Regular","Menlo","Monaco","Consolas",'"Liberation Mono"','"Courier New"',"monospace"]},fontSize:{xs:["0.75rem",{lineHeight:"1rem"}],sm:["0.875rem",{lineHeight:"1.25rem"}],base:["1rem",{lineHeight:"1.5rem"}],lg:["1.125rem",{lineHeight:"1.75rem"}],xl:["1.25rem",{lineHeight:"1.75rem"}],"2xl":["1.5rem",{lineHeight:"2rem"}],"3xl":["1.875rem",{lineHeight:"2.25rem"}],"4xl":["2.25rem",{lineHeight:"2.5rem"}],"5xl":["3rem",{lineHeight:"1"}],"6xl":["3.75rem",{lineHeight:"1"}],"7xl":["4.5rem",{lineHeight:"1"}],"8xl":["6rem",{lineHeight:"1"}],"9xl":["8rem",{lineHeight:"1"}]},fontWeight:{thin:"100",extralight:"200",light:"300",normal:"400",medium:"500",semibold:"600",bold:"700",extrabold:"800",black:"900"},gap:({theme:e})=>e("spacing"),gradientColorStops:({theme:e})=>e("colors"),gradientColorStopPositions:{"0%":"0%","5%":"5%","10%":"10%","15%":"15%","20%":"20%","25%":"25%","30%":"30%","35%":"35%","40%":"40%","45%":"45%","50%":"50%","55%":"55%","60%":"60%","65%":"65%","70%":"70%","75%":"75%","80%":"80%","85%":"85%","90%":"90%","95%":"95%","100%":"100%",...ne},grayscale:{0:"0",DEFAULT:"100%",...ne},gridAutoColumns:{auto:"auto",min:"min-content",max:"max-content",fr:"minmax(0, 1fr)"},gridAutoRows:{auto:"auto",min:"min-content",max:"max-content",fr:"minmax(0, 1fr)"},gridColumn:{auto:"auto","span-1":"span 1 / span 1","span-2":"span 2 / span 2","span-3":"span 3 / span 3","span-4":"span 4 / span 4","span-5":"span 5 / span 5","span-6":"span 6 / span 6","span-7":"span 7 / span 7","span-8":"span 8 / span 8","span-9":"span 9 / span 9","span-10":"span 10 / span 10","span-11":"span 11 / span 11","span-12":"span 12 / span 12","span-full":"1 / -1"},gridColumnEnd:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13",...ce},gridColumnStart:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13",...ce},gridRow:{auto:"auto","span-1":"span 1 / span 1","span-2":"span 2 / span 2","span-3":"span 3 / span 3","span-4":"span 4 / span 4","span-5":"span 5 / span 5","span-6":"span 6 / span 6","span-7":"span 7 / span 7","span-8":"span 8 / span 8","span-9":"span 9 / span 9","span-10":"span 10 / span 10","span-11":"span 11 / span 11","span-12":"span 12 / span 12","span-full":"1 / -1"},gridRowEnd:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13",...ce},gridRowStart:{auto:"auto",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",13:"13",...ce},gridTemplateColumns:{none:"none",subgrid:"subgrid",1:"repeat(1, minmax(0, 1fr))",2:"repeat(2, minmax(0, 1fr))",3:"repeat(3, minmax(0, 1fr))",4:"repeat(4, minmax(0, 1fr))",5:"repeat(5, minmax(0, 1fr))",6:"repeat(6, minmax(0, 1fr))",7:"repeat(7, minmax(0, 1fr))",8:"repeat(8, minmax(0, 1fr))",9:"repeat(9, minmax(0, 1fr))",10:"repeat(10, minmax(0, 1fr))",11:"repeat(11, minmax(0, 1fr))",12:"repeat(12, minmax(0, 1fr))",...Uo},gridTemplateRows:{none:"none",subgrid:"subgrid",1:"repeat(1, minmax(0, 1fr))",2:"repeat(2, minmax(0, 1fr))",3:"repeat(3, minmax(0, 1fr))",4:"repeat(4, minmax(0, 1fr))",5:"repeat(5, minmax(0, 1fr))",6:"repeat(6, minmax(0, 1fr))",7:"repeat(7, minmax(0, 1fr))",8:"repeat(8, minmax(0, 1fr))",9:"repeat(9, minmax(0, 1fr))",10:"repeat(10, minmax(0, 1fr))",11:"repeat(11, minmax(0, 1fr))",12:"repeat(12, minmax(0, 1fr))",...Uo},height:({theme:e})=>({auto:"auto","1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%",full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content",...e("spacing")}),hueRotate:{0:"0deg",15:"15deg",30:"30deg",60:"60deg",90:"90deg",180:"180deg",...St},inset:({theme:e})=>({auto:"auto","1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%",full:"100%",...e("spacing")}),invert:{0:"0",DEFAULT:"100%",...ne},keyframes:{spin:{to:{transform:"rotate(360deg)"}},ping:{"75%, 100%":{transform:"scale(2)",opacity:"0"}},pulse:{"50%":{opacity:".5"}},bounce:{"0%, 100%":{transform:"translateY(-25%)",animationTimingFunction:"cubic-bezier(0.8,0,1,1)"},"50%":{transform:"none",animationTimingFunction:"cubic-bezier(0,0,0.2,1)"}}},letterSpacing:{tighter:"-0.05em",tight:"-0.025em",normal:"0em",wide:"0.025em",wider:"0.05em",widest:"0.1em"},lineHeight:{none:"1",tight:"1.25",snug:"1.375",normal:"1.5",relaxed:"1.625",loose:"2",3:".75rem",4:"1rem",5:"1.25rem",6:"1.5rem",7:"1.75rem",8:"2rem",9:"2.25rem",10:"2.5rem"},listStyleType:{none:"none",disc:"disc",decimal:"decimal"},listStyleImage:{none:"none"},margin:({theme:e})=>({auto:"auto",...e("spacing")}),lineClamp:{1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",...ce},maxHeight:({theme:e})=>({none:"none",full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content",...e("spacing")}),maxWidth:({theme:e})=>({none:"none",xs:"20rem",sm:"24rem",md:"28rem",lg:"32rem",xl:"36rem","2xl":"42rem","3xl":"48rem","4xl":"56rem","5xl":"64rem","6xl":"72rem","7xl":"80rem",full:"100%",min:"min-content",max:"max-content",fit:"fit-content",prose:"65ch",...e("spacing")}),minHeight:({theme:e})=>({full:"100%",screen:"100vh",svh:"100svh",lvh:"100lvh",dvh:"100dvh",min:"min-content",max:"max-content",fit:"fit-content",...e("spacing")}),minWidth:({theme:e})=>({full:"100%",min:"min-content",max:"max-content",fit:"fit-content",...e("spacing")}),objectPosition:{bottom:"bottom",center:"center",left:"left","left-bottom":"left bottom","left-top":"left top",right:"right","right-bottom":"right bottom","right-top":"right top",top:"top"},opacity:{0:"0",5:"0.05",10:"0.1",15:"0.15",20:"0.2",25:"0.25",30:"0.3",35:"0.35",40:"0.4",45:"0.45",50:"0.5",55:"0.55",60:"0.6",65:"0.65",70:"0.7",75:"0.75",80:"0.8",85:"0.85",90:"0.9",95:"0.95",100:"1",...ne},order:{first:"-9999",last:"9999",none:"0",1:"1",2:"2",3:"3",4:"4",5:"5",6:"6",7:"7",8:"8",9:"9",10:"10",11:"11",12:"12",...ce},outlineColor:({theme:e})=>e("colors"),outlineOffset:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px",...Ne},outlineWidth:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px",...Ne},padding:({theme:e})=>e("spacing"),placeholderColor:({theme:e})=>e("colors"),placeholderOpacity:({theme:e})=>e("opacity"),ringColor:({theme:e})=>({DEFAULT:"currentcolor",...e("colors")}),ringOffsetColor:({theme:e})=>e("colors"),ringOffsetWidth:{0:"0px",1:"1px",2:"2px",4:"4px",8:"8px",...Ne},ringOpacity:({theme:e})=>({DEFAULT:"0.5",...e("opacity")}),ringWidth:{DEFAULT:"3px",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px",...Ne},rotate:{0:"0deg",1:"1deg",2:"2deg",3:"3deg",6:"6deg",12:"12deg",45:"45deg",90:"90deg",180:"180deg",...St},saturate:{0:"0",50:".5",100:"1",150:"1.5",200:"2",...ne},scale:{0:"0",50:".5",75:".75",90:".9",95:".95",100:"1",105:"1.05",110:"1.1",125:"1.25",150:"1.5",...ne},screens:{sm:"40rem",md:"48rem",lg:"64rem",xl:"80rem","2xl":"96rem"},scrollMargin:({theme:e})=>e("spacing"),scrollPadding:({theme:e})=>e("spacing"),sepia:{0:"0",DEFAULT:"100%",...ne},skew:{0:"0deg",1:"1deg",2:"2deg",3:"3deg",6:"6deg",12:"12deg",...St},space:({theme:e})=>e("spacing"),spacing:{px:"1px",0:"0px",.5:"0.125rem",1:"0.25rem",1.5:"0.375rem",2:"0.5rem",2.5:"0.625rem",3:"0.75rem",3.5:"0.875rem",4:"1rem",5:"1.25rem",6:"1.5rem",7:"1.75rem",8:"2rem",9:"2.25rem",10:"2.5rem",11:"2.75rem",12:"3rem",14:"3.5rem",16:"4rem",20:"5rem",24:"6rem",28:"7rem",32:"8rem",36:"9rem",40:"10rem",44:"11rem",48:"12rem",52:"13rem",56:"14rem",60:"15rem",64:"16rem",72:"18rem",80:"20rem",96:"24rem"},stroke:({theme:e})=>({none:"none",...e("colors")}),strokeWidth:{0:"0",1:"1",2:"2",...ce},supports:{},data:{},textColor:({theme:e})=>e("colors"),textDecorationColor:({theme:e})=>e("colors"),textDecorationThickness:{auto:"auto","from-font":"from-font",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px",...Ne},textIndent:({theme:e})=>e("spacing"),textOpacity:({theme:e})=>e("opacity"),textUnderlineOffset:{auto:"auto",0:"0px",1:"1px",2:"2px",4:"4px",8:"8px",...Ne},transformOrigin:{center:"center",top:"top","top-right":"top right",right:"right","bottom-right":"bottom right",bottom:"bottom","bottom-left":"bottom left",left:"left","top-left":"top left"},transitionDelay:{0:"0s",75:"75ms",100:"100ms",150:"150ms",200:"200ms",300:"300ms",500:"500ms",700:"700ms",1e3:"1000ms",...Lo},transitionDuration:{DEFAULT:"150ms",0:"0s",75:"75ms",100:"100ms",150:"150ms",200:"200ms",300:"300ms",500:"500ms",700:"700ms",1e3:"1000ms",...Lo},transitionProperty:{none:"none",all:"all",DEFAULT:"color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter",colors:"color, background-color, border-color, outline-color, text-decoration-color, fill, stroke",opacity:"opacity",shadow:"box-shadow",transform:"transform"},transitionTimingFunction:{DEFAULT:"cubic-bezier(0.4, 0, 0.2, 1)",linear:"linear",in:"cubic-bezier(0.4, 0, 1, 1)",out:"cubic-bezier(0, 0, 0.2, 1)","in-out":"cubic-bezier(0.4, 0, 0.2, 1)"},translate:({theme:e})=>({"1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%",full:"100%",...e("spacing")}),size:({theme:e})=>({auto:"auto","1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%",min:"min-content",max:"max-content",fit:"fit-content",...e("spacing")}),width:({theme:e})=>({auto:"auto","1/2":"50%","1/3":"33.333333%","2/3":"66.666667%","1/4":"25%","2/4":"50%","3/4":"75%","1/5":"20%","2/5":"40%","3/5":"60%","4/5":"80%","1/6":"16.666667%","2/6":"33.333333%","3/6":"50%","4/6":"66.666667%","5/6":"83.333333%","1/12":"8.333333%","2/12":"16.666667%","3/12":"25%","4/12":"33.333333%","5/12":"41.666667%","6/12":"50%","7/12":"58.333333%","8/12":"66.666667%","9/12":"75%","10/12":"83.333333%","11/12":"91.666667%",full:"100%",screen:"100vw",svw:"100svw",lvw:"100lvw",dvw:"100dvw",min:"min-content",max:"max-content",fit:"fit-content",...e("spacing")}),willChange:{auto:"auto",scroll:"scroll-position",contents:"contents",transform:"transform"},zIndex:{auto:"auto",0:"0",10:"10",20:"20",30:"30",40:"40",50:"50",...ce}};function Ko(e){return{theme:{...zo,colors:({theme:r})=>r("color",{}),extend:{fontSize:({theme:r})=>({...r("text",{})}),boxShadow:({theme:r})=>({...r("shadow",{})}),animation:({theme:r})=>({...r("animate",{})}),aspectRatio:({theme:r})=>({...r("aspect",{})}),borderRadius:({theme:r})=>({...r("radius",{})}),screens:({theme:r})=>({...r("breakpoint",{})}),letterSpacing:({theme:r})=>({...r("tracking",{})}),lineHeight:({theme:r})=>({...r("leading",{})}),transitionDuration:{DEFAULT:e.get(["--default-transition-duration"])??null},transitionTimingFunction:{DEFAULT:e.get(["--default-transition-timing-function"])??null},maxWidth:({theme:r})=>({...r("container",{})})}}}}var Wn={blocklist:[],future:{},experimental:{},prefix:"",important:!1,darkMode:null,theme:{},plugins:[],content:{files:[]}};function sr(e,r){let o={design:e,configs:[],plugins:[],content:{files:[]},theme:{},extend:{},result:structuredClone(Wn)};for(let l of r)ar(o,l);for(let l of o.configs)"darkMode"in l&&l.darkMode!==void 0&&(o.result.darkMode=l.darkMode??null),"prefix"in l&&l.prefix!==void 0&&(o.result.prefix=l.prefix??""),"blocklist"in l&&l.blocklist!==void 0&&(o.result.blocklist=l.blocklist??[]),"important"in l&&l.important!==void 0&&(o.result.important=l.important??!1);let t=Hn(o);return{resolvedConfig:{...o.result,content:o.content,theme:o.theme,plugins:o.plugins},replacedThemeKeys:t}}function Bn(e,r){if(Array.isArray(e)&&Ke(e[0]))return e.concat(r);if(Array.isArray(r)&&Ke(r[0])&&Ke(e))return[e,...r];if(Array.isArray(r))return r}function ar(e,{config:r,base:o,path:t,reference:l,src:s}){let a=[];for(let f of r.plugins??[])"__isOptionsFunction"in f?a.push({...f(),reference:l,src:s}):"handler"in f?a.push({...f,reference:l,src:s}):a.push({handler:f,reference:l,src:s});if(Array.isArray(r.presets)&&r.presets.length===0)throw new Error("Error in the config file/plugin/preset. An empty preset (`preset: []`) is not currently supported.");for(let f of r.presets??[])ar(e,{path:t,base:o,config:f,reference:l,src:s});for(let f of a)e.plugins.push(f),f.config&&ar(e,{path:t,base:o,config:f.config,reference:!!f.reference,src:f.src??s});let m=r.content??[],h=Array.isArray(m)?m:m.files;for(let f of h)e.content.files.push(typeof f=="object"?f:{base:o,pattern:f});e.configs.push(r)}function Hn(e){let r=new Set,o=At(e.design,()=>e.theme,l),t=Object.assign(o,{theme:o,colors:Ct});function l(s){return typeof s=="function"?s(t)??null:s??null}for(let s of e.configs){let a=s.theme??{},m=a.extend??{};for(let h in a)h!=="extend"&&r.add(h);Object.assign(e.theme,a);for(let h in m)e.extend[h]??=[],e.extend[h].push(m[h])}delete e.theme.extend;for(let s in e.extend){let a=[e.theme[s],...e.extend[s]];e.theme[s]=()=>{let m=a.map(l);return Je({},m,Bn)}}for(let s in e.theme)e.theme[s]=l(e.theme[s]);if(e.theme.screens&&typeof e.theme.screens=="object")for(let s of Object.keys(e.theme.screens)){let a=e.theme.screens[s];a&&typeof a=="object"&&("raw"in a||"max"in a||"min"in a&&(e.theme.screens[s]=a.min))}return r}function Do(e,r){let o=e.theme.container||{};if(typeof o!="object"||o===null)return;let t=qn(o,r);t.length!==0&&r.utilities.static("container",()=>t.map(re))}function qn({center:e,padding:r,screens:o},t){let l=[],s=null;if(e&&l.push(n("margin-inline","auto")),(typeof r=="string"||typeof r=="object"&&r!==null&&"DEFAULT"in r)&&l.push(n("padding-inline",typeof r=="string"?r:r.DEFAULT)),typeof o=="object"&&o!==null){s=new Map;let a=Array.from(t.theme.namespace("--breakpoint").entries());if(a.sort((m,h)=>$e(m[1],h[1],"asc")),a.length>0){let[m]=a[0];l.push(M("@media",`(width >= --theme(--breakpoint-${m}))`,[n("max-width","none")]))}for(let[m,h]of Object.entries(o)){if(typeof h=="object")if("min"in h)h=h.min;else continue;s.set(m,M("@media",`(width >= ${h})`,[n("max-width",h)]))}}if(typeof r=="object"&&r!==null){let a=Object.entries(r).filter(([m])=>m!=="DEFAULT").map(([m,h])=>[m,t.theme.resolveValue(m,["--breakpoint"]),h]).filter(Boolean);a.sort((m,h)=>$e(m[1],h[1],"asc"));for(let[m,,h]of a)if(s&&s.has(m))s.get(m).nodes.push(n("padding-inline",h));else{if(s)continue;l.push(M("@media",`(width >= theme(--breakpoint-${m}))`,[n("padding-inline",h)]))}}if(s)for(let[,a]of s)l.push(a);return l}function Io({addVariant:e,config:r}){let o=r("darkMode",null),[t,l=".dark"]=Array.isArray(o)?o:[o];if(t==="variant"){let s;if(Array.isArray(l)||typeof l=="function"?s=l:typeof l=="string"&&(s=[l]),Array.isArray(s))for(let a of s)a===".dark"?(t=!1,console.warn('When using `variant` for `darkMode`, you must provide a selector.\nExample: `darkMode: ["variant", ".your-selector &"]`')):a.includes("&")||(t=!1,console.warn('When using `variant` for `darkMode`, your selector must contain `&`.\nExample `darkMode: ["variant", ".your-selector &"]`'));l=s}t===null||(t==="selector"?e("dark",`&:where(${l}, ${l} *)`):t==="media"?e("dark","@media (prefers-color-scheme: dark)"):t==="variant"?e("dark",l):t==="class"&&e("dark",`&:is(${l} *)`))}function jo(e){for(let[o,t]of[["t","top"],["tr","top right"],["r","right"],["br","bottom right"],["b","bottom"],["bl","bottom left"],["l","left"],["tl","top left"]])e.utilities.suggest(`bg-gradient-to-${o}`,()=>[]),e.utilities.static(`bg-gradient-to-${o}`,()=>[n("--tw-gradient-position",`to ${t} in oklab`),n("background-image","linear-gradient(var(--tw-gradient-stops))")]);e.utilities.suggest("bg-left-top",()=>[]),e.utilities.static("bg-left-top",()=>[n("background-position","left top")]),e.utilities.suggest("bg-right-top",()=>[]),e.utilities.static("bg-right-top",()=>[n("background-position","right top")]),e.utilities.suggest("bg-left-bottom",()=>[]),e.utilities.static("bg-left-bottom",()=>[n("background-position","left bottom")]),e.utilities.suggest("bg-right-bottom",()=>[]),e.utilities.static("bg-right-bottom",()=>[n("background-position","right bottom")]),e.utilities.suggest("object-left-top",()=>[]),e.utilities.static("object-left-top",()=>[n("object-position","left top")]),e.utilities.suggest("object-right-top",()=>[]),e.utilities.static("object-right-top",()=>[n("object-position","right top")]),e.utilities.suggest("object-left-bottom",()=>[]),e.utilities.static("object-left-bottom",()=>[n("object-position","left bottom")]),e.utilities.suggest("object-right-bottom",()=>[]),e.utilities.static("object-right-bottom",()=>[n("object-position","right bottom")]),e.utilities.suggest("max-w-screen",()=>[]),e.utilities.functional("max-w-screen",o=>{if(!o.value||o.value.kind==="arbitrary")return;let t=e.theme.resolve(o.value.value,["--breakpoint"]);if(t)return[n("max-width",t)]}),e.utilities.suggest("overflow-ellipsis",()=>[]),e.utilities.static("overflow-ellipsis",()=>[n("text-overflow","ellipsis")]),e.utilities.suggest("decoration-slice",()=>[]),e.utilities.static("decoration-slice",()=>[n("-webkit-box-decoration-break","slice"),n("box-decoration-break","slice")]),e.utilities.suggest("decoration-clone",()=>[]),e.utilities.static("decoration-clone",()=>[n("-webkit-box-decoration-break","clone"),n("box-decoration-break","clone")]),e.utilities.suggest("flex-shrink",()=>[]),e.utilities.functional("flex-shrink",o=>{if(!o.modifier){if(!o.value)return[n("flex-shrink","1")];if(o.value.kind==="arbitrary")return[n("flex-shrink",o.value.value)];if(R(o.value.value))return[n("flex-shrink",o.value.value)]}}),e.utilities.suggest("flex-grow",()=>[]),e.utilities.functional("flex-grow",o=>{if(!o.modifier){if(!o.value)return[n("flex-grow","1")];if(o.value.kind==="arbitrary")return[n("flex-grow",o.value.value)];if(R(o.value.value))return[n("flex-grow",o.value.value)]}}),e.utilities.suggest("order-none",()=>[]),e.utilities.static("order-none",()=>[n("order","0")]),e.utilities.suggest("break-words",()=>[]),e.utilities.static("break-words",()=>[n("overflow-wrap","break-word")]);for(let[o,t]of[["start","inset-inline-start"],["end","inset-inline-end"]]){let l=function({negative:s}){return a=>{if(a.value===null)return;if(a.value.kind==="arbitrary"){if(a.modifier)return;let h=a.value.value;return[n(t,s?`calc(${h} * -1)`:h)]}let m=e.theme.resolve(a.value.fraction??a.value.value,["--inset","--spacing"]);if(m===null&&a.value.fraction){let[h,f]=U(a.value.fraction,"/");if(!R(h)||!R(f))return;m=`calc(${a.value.fraction} * 100%)`}if(m===null&&s){let h=e.theme.resolve(null,["--spacing"]);if(h&&le(a.value.value)&&(m=`calc(${h} * -${a.value.value})`,m!==null))return[n(t,m)]}if(m===null){let h=e.theme.resolve(null,["--spacing"]);h&&le(a.value.value)&&(m=`calc(${h} * ${a.value.value})`)}if(m!==null)return[n(t,s?`calc(${m} * -1)`:m)]}};var r=l;e.utilities.static(`${o}-auto`,()=>[n(t,"auto")]),e.utilities.static(`${o}-full`,()=>[n(t,"100%")]),e.utilities.static(`-${o}-full`,()=>[n(t,"-100%")]),e.utilities.static(`${o}-px`,()=>[n(t,"1px")]),e.utilities.static(`-${o}-px`,()=>[n(t,"-1px")]),e.utilities.functional(`-${o}`,l({negative:!0})),e.utilities.functional(o,l({negative:!1}))}}function Fo(e,r){let o=e.theme.screens||{},t=r.variants.get("min")?.order??0,l=[];for(let[a,m]of Object.entries(o)){let c=function(v){r.variants.static(a,g=>{g.nodes=[M("@media",p,g.nodes)]},{order:v})};var s=c;let h=r.variants.get(a),f=r.theme.resolveValue(a,["--breakpoint"]);if(h&&f&&!r.theme.hasDefault(`--breakpoint-${a}`))continue;let u=!0;typeof m=="string"&&(u=!1);let p=Gn(m);u?l.push(c):c(t)}if(l.length!==0){for(let[,a]of r.variants.variants)a.order>t&&(a.order+=l.length);r.variants.compareFns=new Map(Array.from(r.variants.compareFns).map(([a,m])=>(a>t&&(a+=l.length),[a,m])));for(let[a,m]of l.entries())m(t+a+1)}}function Gn(e){return(Array.isArray(e)?e:[e]).map(o=>typeof o=="string"?{min:o}:o&&typeof o=="object"?o:null).map(o=>{if(o===null)return null;if("raw"in o)return o.raw;let t="";return o.max!==void 0&&(t+=`${o.max} >= `),t+="width",o.min!==void 0&&(t+=` >= ${o.min}`),`(${t})`}).filter(Boolean).join(", ")}function Mo(e,r){let o=e.theme.aria||{},t=e.theme.supports||{},l=e.theme.data||{};if(Object.keys(o).length>0){let s=r.variants.get("aria"),a=s?.applyFn,m=s?.compounds;r.variants.functional("aria",(h,f)=>{let u=f.value;return u&&u.kind==="named"&&u.value in o?a?.(h,{...f,value:{kind:"arbitrary",value:o[u.value]}}):a?.(h,f)},{compounds:m})}if(Object.keys(t).length>0){let s=r.variants.get("supports"),a=s?.applyFn,m=s?.compounds;r.variants.functional("supports",(h,f)=>{let u=f.value;return u&&u.kind==="named"&&u.value in t?a?.(h,{...f,value:{kind:"arbitrary",value:t[u.value]}}):a?.(h,f)},{compounds:m})}if(Object.keys(l).length>0){let s=r.variants.get("data"),a=s?.applyFn,m=s?.compounds;r.variants.functional("data",(h,f)=>{let u=f.value;return u&&u.kind==="named"&&u.value in l?a?.(h,{...f,value:{kind:"arbitrary",value:l[u.value]}}):a?.(h,f)},{compounds:m})}}var Zn=/^[a-z]+$/;async function Bo({designSystem:e,base:r,ast:o,loadModule:t,sources:l}){let s=0,a=[],m=[];O(o,(p,c)=>{if(p.kind!=="at-rule")return;let v=Be(c);if(p.name==="@plugin"){if(v.parent!==null)throw new Error("`@plugin` cannot be nested.");let g=p.params.slice(1,-1);if(g.length===0)throw new Error("`@plugin` must have a path.");let b={};for(let x of p.nodes??[]){if(x.kind!=="declaration")throw new Error(`Unexpected \`@plugin\` option: + +${ae([x])} + +\`@plugin\` options must be a flat list of declarations.`);if(x.value===void 0)continue;let T=x.value,A=U(T,",").map(w=>{if(w=w.trim(),w==="null")return null;if(w==="true")return!0;if(w==="false")return!1;if(Number.isNaN(Number(w))){if(w[0]==='"'&&w[w.length-1]==='"'||w[0]==="'"&&w[w.length-1]==="'")return w.slice(1,-1);if(w[0]==="{"&&w[w.length-1]==="}")throw new Error(`Unexpected \`@plugin\` option: Value of declaration \`${ae([x]).trim()}\` is not supported. + +Using an object as a plugin option is currently only supported in JavaScript configuration files.`)}else return Number(w);return w});b[x.property]=A.length===1?A[0]:A}return a.push([{id:g,base:v.context.base,reference:!!v.context.reference,src:p.src},Object.keys(b).length>0?b:null]),s|=4,V.Replace([])}if(p.name==="@config"){if(p.nodes.length>0)throw new Error("`@config` cannot have a body.");if(v.parent!==null)throw new Error("`@config` cannot be nested.");return m.push({id:p.params.slice(1,-1),base:v.context.base,reference:!!v.context.reference,src:p.src}),s|=4,V.Replace([])}}),jo(e);let h=e.resolveThemeValue;if(e.resolveThemeValue=function(c,v){return c.startsWith("--")?h(c,v):(s|=Wo({designSystem:e,base:r,ast:o,sources:l,configs:[],pluginDetails:[]}),e.resolveThemeValue(c,v))},!a.length&&!m.length)return 0;let[f,u]=await Promise.all([Promise.all(m.map(async({id:p,base:c,reference:v,src:g})=>{let b=await t(p,c,"config");return{path:p,base:b.base,config:b.module,reference:v,src:g}})),Promise.all(a.map(async([{id:p,base:c,reference:v,src:g},b])=>{let x=await t(p,c,"plugin");return{path:p,base:x.base,plugin:x.module,options:b,reference:v,src:g}}))]);return s|=Wo({designSystem:e,base:r,ast:o,sources:l,configs:f,pluginDetails:u}),s}function Wo({designSystem:e,base:r,ast:o,sources:t,configs:l,pluginDetails:s}){let a=0,h=[...s.map(b=>{if(!b.options)return{config:{plugins:[b.plugin]},base:b.base,reference:b.reference,src:b.src};if("__isOptionsFunction"in b.plugin)return{config:{plugins:[b.plugin(b.options)]},base:b.base,reference:b.reference,src:b.src};throw new Error(`The plugin "${b.path}" does not accept options`)}),...l],{resolvedConfig:f}=sr(e,[{config:Ko(e.theme),base:r,reference:!0,src:void 0},...h,{config:{plugins:[Io]},base:r,reference:!0,src:void 0}]),{resolvedConfig:u,replacedThemeKeys:p}=sr(e,h),c={designSystem:e,ast:o,resolvedConfig:f,featuresRef:{set current(b){a|=b}}},v=lr({...c,referenceMode:!1,src:void 0}),g=e.resolveThemeValue;e.resolveThemeValue=function(x,T){if(x[0]==="-"&&x[1]==="-")return g(x,T);let A=v.theme(x,void 0);if(Array.isArray(A)&&A.length===2)return A[0];if(Array.isArray(A))return A.join(", ");if(typeof A=="object"&&A!==null&&"DEFAULT"in A)return A.DEFAULT;if(typeof A=="string")return A};for(let{handler:b,reference:x,src:T}of f.plugins){let A=lr({...c,referenceMode:x??!1,src:T});b(A)}if(To(e,u,p),_o(e,u),Mo(u,e),Fo(u,e),Do(u,e),!e.theme.prefix&&f.prefix){if(f.prefix.endsWith("-")&&(f.prefix=f.prefix.slice(0,-1),console.warn(`The prefix "${f.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only and is written as a variant before all utilities. We have fixed up the prefix for you. Remove the trailing \`-\` to silence this warning.`)),!Zn.test(f.prefix))throw new Error(`The prefix "${f.prefix}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`);e.theme.prefix=f.prefix}if(!e.important&&f.important===!0&&(e.important=!0),typeof f.important=="string"){let b=f.important;O(o,(x,T)=>{if(x.kind!=="at-rule"||x.name!=="@tailwind"||x.params!=="utilities")return;let A=Be(T);return A.parent?.kind==="rule"&&A.parent.selector===b?V.Stop:V.ReplaceStop(G(b,[x]))})}for(let b of f.blocklist)e.invalidCandidates.add(b);for(let b of f.content.files){if("raw"in b)throw new Error(`Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry: + +${JSON.stringify(b,null,2)} + +This feature is not currently supported.`);let x=!1;b.pattern[0]=="!"&&(x=!0,b.pattern=b.pattern.slice(1)),t.push({...b,negated:x})}return a}function Ho({ast:e}){let r=new H(l=>tt(l.code)),o=new H(l=>({url:l.file,content:l.code,ignore:!1})),t={file:null,sources:[],mappings:[]};O(e,l=>{if(!l.src||!l.dst)return;let s=o.get(l.src[0]);if(!s.content)return;let a=r.get(l.src[0]),m=r.get(l.dst[0]),h=s.content.slice(l.src[1],l.src[2]),f=0;for(let c of h.split(` +`)){if(c.trim()!==""){let v=a.find(l.src[1]+f),g=m.find(l.dst[1]);t.mappings.push({name:null,originalPosition:{source:s,...v},generatedPosition:g})}f+=c.length,f+=1}let u=a.find(l.src[2]),p=m.find(l.dst[2]);t.mappings.push({name:null,originalPosition:{source:s,...u},generatedPosition:p})});for(let l of r.keys())t.sources.push(o.get(l));return t.mappings.sort((l,s)=>l.generatedPosition.line-s.generatedPosition.line||l.generatedPosition.column-s.generatedPosition.column||(l.originalPosition?.line??0)-(s.originalPosition?.line??0)||(l.originalPosition?.column??0)-(s.originalPosition?.column??0)),t}var qo=/^(-?\d+)\.\.(-?\d+)(?:\.\.(-?\d+))?$/;function Nt(e){let r=e.indexOf("{");if(r===-1)return[e];let o=[],t=e.slice(0,r),l=e.slice(r),s=0,a=l.lastIndexOf("}");for(let p=0;pNt(p));let u=Nt(h);for(let p of u)for(let c of f)o.push(t+c+p);return o}function Yn(e){return qo.test(e)}function Qn(e){let r=e.match(qo);if(!r)return[e];let[,o,t,l]=r,s=l?parseInt(l,10):void 0,a=[];if(/^-?\d+$/.test(o)&&/^-?\d+$/.test(t)){let m=parseInt(o,10),h=parseInt(t,10);if(s===void 0&&(s=m<=h?1:-1),s===0)throw new Error("Step cannot be zero in sequence expansion.");let f=m0&&(s=-s);for(let u=m;f?u<=h:u>=h;u+=s)a.push(u.toString())}return a}function Go(e,r){let o=new Set,t=new Set,l=[];function s(a,m=[]){if(e.has(a)&&!o.has(a)){t.has(a)&&r.onCircularDependency?.(m,a),t.add(a);for(let h of e.get(a)??[])m.push(a),s(h,m),m.pop();o.add(a),t.delete(a),l.push(a)}}for(let a of e.keys())s(a);return l}var Jn=/^[a-z]+$/;function Xn(){throw new Error("No `loadModule` function provided to `compile`")}function el(){throw new Error("No `loadStylesheet` function provided to `compile`")}function tl(e){let r=0,o=null;for(let t of U(e," "))t==="reference"?r|=2:t==="inline"?r|=1:t==="default"?r|=4:t==="static"?r|=8:t.startsWith("prefix(")&&t.endsWith(")")&&(o=t.slice(7,-1));return[r,o]}async function rl(e,{base:r="",from:o,loadModule:t=Xn,loadStylesheet:l=el}={}){let s=0;e=[de({base:r},e)],s|=await nr(e,r,l,0,o!==void 0);let a=null,m=new at,h=new Map,f=new Map,u=[],p=null,c=null,v=[],g=[],b=[],x=[],T=null;O(e,(w,K)=>{if(w.kind!=="at-rule")return;let L=Be(K);if(w.name==="@tailwind"&&(w.params==="utilities"||w.params.startsWith("utilities"))){if(c!==null)return V.Replace([]);if(L.context.reference)return V.Replace([]);let z=U(w.params," ");for(let I of z)if(I.startsWith("source(")){let P=I.slice(7,-1);if(P==="none"){T=P;continue}if(P[0]==='"'&&P[P.length-1]!=='"'||P[0]==="'"&&P[P.length-1]!=="'"||P[0]!=="'"&&P[0]!=='"')throw new Error("`source(\u2026)` paths must be quoted.");T={base:L.context.sourceBase??L.context.base,pattern:P.slice(1,-1)}}c=w,s|=16}if(w.name==="@utility"){if(L.parent!==null)throw new Error("`@utility` cannot be nested.");if(w.nodes.length===0)throw new Error(`\`@utility ${w.params}\` is empty. Utilities should include at least one property.`);let z=co(w);if(z===null){if(!w.params.endsWith("-*")){if(w.params.endsWith("*"))throw new Error(`\`@utility ${w.params}\` defines an invalid utility name. A functional utility must end in \`-*\`.`);if(w.params.includes("*"))throw new Error(`\`@utility ${w.params}\` defines an invalid utility name. The dynamic portion marked by \`-*\` must appear once at the end.`)}throw new Error(`\`@utility ${w.params}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`)}u.push(z)}if(w.name==="@source"){if(w.nodes.length>0)throw new Error("`@source` cannot have a body.");if(L.parent!==null)throw new Error("`@source` cannot be nested.");let z=!1,I=!1,P=w.params;if(P[0]==="n"&&P.startsWith("not ")&&(z=!0,P=P.slice(4)),P[0]==="i"&&P.startsWith("inline(")&&(I=!0,P=P.slice(7,-1).trim()),P[0]==='"'&&P[P.length-1]!=='"'||P[0]==="'"&&P[P.length-1]!=="'"||P[0]!=="'"&&P[0]!=='"')throw new Error("`@source` paths must be quoted.");let D=P.slice(1,-1);if(I){let B=z?x:b,W=U(D," ");for(let X of W)for(let te of Nt(X))B.push(te)}else g.push({base:L.context.base,pattern:D,negated:z});return V.ReplaceSkip([])}if(w.name==="@variant"&&(L.parent===null?w.nodes.length===0?w.name="@custom-variant":(O(w.nodes,z=>{if(z.kind==="at-rule"&&z.name==="@slot")return w.name="@custom-variant",V.Stop}),w.name==="@variant"&&v.push(w)):v.push(w)),w.name==="@custom-variant"){if(L.parent!==null)throw new Error("`@custom-variant` cannot be nested.");let[z,I]=U(w.params," ");if(!bt.test(z))throw new Error(`\`@custom-variant ${z}\` defines an invalid variant name. Variants should only contain alphanumeric, dashes, or underscore characters and start with a lowercase letter or number.`);if(w.nodes.length>0&&I)throw new Error(`\`@custom-variant ${z}\` cannot have both a selector and a body.`);if(w.nodes.length===0){if(!I)throw new Error(`\`@custom-variant ${z}\` has no selector or body.`);let P=U(I.slice(1,-1),",");if(P.length===0||P.some(W=>W.trim()===""))throw new Error(`\`@custom-variant ${z} (${P.join(",")})\` selector is invalid.`);let D=[],B=[];for(let W of P)W=W.trim(),W[0]==="@"?D.push(W):B.push(W);h.set(z,W=>{W.variants.static(z,X=>{let te=[];B.length>0&&te.push(G(B.join(", "),X.nodes));for(let i of D)te.push(Y(i,X.nodes));X.nodes=te},{compounds:Ve([...B,...D])})}),f.set(z,new Set)}else{let P=new Set;O(w.nodes,D=>{D.kind==="at-rule"&&D.name==="@variant"&&P.add(D.params)}),h.set(z,D=>{D.variants.fromAst(z,w.nodes,D)}),f.set(z,P)}return V.ReplaceSkip([])}if(w.name==="@media"){let z=U(w.params," "),I=[];for(let P of z)if(P.startsWith("source(")){let D=P.slice(7,-1);O(w.nodes,B=>{if(B.kind==="at-rule"&&B.name==="@tailwind"&&B.params==="utilities")return B.params+=` source(${D})`,V.ReplaceStop([de({sourceBase:L.context.base},[B])])})}else if(P.startsWith("theme(")){let D=P.slice(6,-1),B=D.includes("reference");O(w.nodes,W=>{if(W.kind!=="context"){if(W.kind!=="at-rule"){if(B)throw new Error('Files imported with `@import "\u2026" theme(reference)` must only contain `@theme` blocks.\nUse `@reference "\u2026";` instead.');return V.Continue}if(W.name==="@theme")return W.params+=" "+D,V.Skip}})}else if(P.startsWith("prefix(")){let D=P.slice(7,-1);O(w.nodes,B=>{if(B.kind==="at-rule"&&B.name==="@theme")return B.params+=` prefix(${D})`,V.Skip})}else P==="important"?a=!0:P==="reference"?w.nodes=[de({reference:!0},w.nodes)]:I.push(P);if(I.length>0)w.params=I.join(" ");else if(z.length>0)return V.Replace(w.nodes);return V.Continue}if(w.name==="@theme"){let[z,I]=tl(w.params);if(s|=64,L.context.reference&&(z|=2),I){if(!Jn.test(I))throw new Error(`The prefix "${I}" is invalid. Prefixes must be lowercase ASCII letters (a-z) only.`);m.prefix=I}return O(w.nodes,P=>{if(P.kind==="at-rule"&&P.name==="@keyframes")return m.addKeyframes(P),V.Skip;if(P.kind==="comment")return;if(P.kind==="declaration"&&P.property.startsWith("--")){m.add(ke(P.property),P.value??"",z,P.src);return}let D=ae([M(w.name,w.params,[P])]).split(` +`).map((B,W,X)=>`${W===0||W>=X.length-2?" ":">"} ${B}`).join(` +`);throw new Error(`\`@theme\` blocks must only contain custom properties or \`@keyframes\`. + +${D}`)}),p?V.ReplaceSkip([]):(p=G(":root, :host",[]),p.src=w.src,V.ReplaceSkip(p))}});let A=yo(m,c?.src);if(a&&(A.important=a),x.length>0)for(let w of x)A.invalidCandidates.add(w);s|=await Bo({designSystem:A,base:r,ast:e,loadModule:t,sources:g});for(let w of h.keys())A.variants.static(w,()=>{});for(let w of Go(f,{onCircularDependency(K,L){let z=ae(K.map((I,P)=>M("@custom-variant",I,[M("@variant",K[P+1]??L,[])]))).replaceAll(";"," { \u2026 }").replace(`@custom-variant ${L} {`,`@custom-variant ${L} { /* \u2190 */`);throw new Error(`Circular dependency detected in custom variants: + +${z}`)}}))h.get(w)?.(A);for(let w of u)w(A);if(p){let w=[];for(let[L,z]of A.theme.entries()){if(z.options&2)continue;let I=n(xe(L),z.value);I.src=z.src,w.push(I)}let K=A.theme.getKeyframes();for(let L of K)e.push(de({theme:!0},[F([L])]));p.nodes=[de({theme:!0},w)]}if(s|=Ye(e,A),s|=Le(e,A),s|=Qe(e,A),c){let w=c;w.kind="context",w.context={}}return O(e,w=>{if(w.kind==="at-rule")return w.name==="@utility"?V.Replace([]):V.Skip}),{designSystem:A,ast:e,sources:g,root:T,utilitiesNode:c,features:s,inlineCandidates:b}}async function ol(e,r={}){let{designSystem:o,ast:t,sources:l,root:s,utilitiesNode:a,features:m,inlineCandidates:h}=await rl(e,r);t.unshift(lt(`! tailwindcss v${pr} | MIT License | https://tailwindcss.com `));function f(g){o.invalidCandidates.add(g)}let u=new Set,p=null,c=0,v=!1;for(let g of h)o.invalidCandidates.has(g)||(u.add(g),v=!0);return{sources:l,root:s,features:m,build(g){if(m===0)return e;if(!a)return p??=Te(t,o,r.polyfills),p;let b=v,x=!1;v=!1;let T=u.size;for(let w of g)if(!o.invalidCandidates.has(w))if(w[0]==="-"&&w[1]==="-"){let K=o.theme.markUsedVariable(w);b||=K,x||=K}else u.add(w),b||=u.size!==T;if(!b)return p??=Te(t,o,r.polyfills),p;let A=Se(u,o,{onInvalidCandidate:f}).astNodes;return r.from&&O(A,w=>{w.src??=a.src}),!x&&c===A.length?(p??=Te(t,o,r.polyfills),p):(c=A.length,a.nodes=A,p=Te(t,o,r.polyfills),p)}}}async function Zo(e,r={}){let o=Pe(e,{from:r.from}),t=await ol(o,r),l=o,s=e;return{...t,build(a){let m=t.build(a);return m===l||(s=ae(m,!!r.from),l=m),s},buildSourceMap(){return Ho({ast:l})}}}var Yo=`@layer theme, base, components, utilities; + +@import './theme.css' layer(theme); +@import './preflight.css' layer(base); +@import './utilities.css' layer(utilities); +`;var Qo=`/* + 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) + 2. Remove default margins and padding + 3. Reset all borders. +*/ + +*, +::after, +::before, +::backdrop, +::file-selector-button { + box-sizing: border-box; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 2 */ + border: 0 solid; /* 3 */ +} + +/* + 1. Use a consistent sensible line-height in all browsers. + 2. Prevent adjustments of font size after orientation changes in iOS. + 3. Use a more readable tab size. + 4. Use the user's configured \`sans\` font-family by default. + 5. Use the user's configured \`sans\` font-feature-settings by default. + 6. Use the user's configured \`sans\` font-variation-settings by default. + 7. Disable tap highlights on iOS. +*/ + +html, +:host { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + tab-size: 4; /* 3 */ + font-family: --theme( + --default-font-family, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + 'Noto Sans', + Arial, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji' + ); /* 4 */ + font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */ + font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ +} + +/* + 1. Add the correct height in Firefox. + 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) + 3. Reset the default border style to a 1px solid border. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* + Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* + Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* + Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; +} + +/* + Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* + 1. Use the user's configured \`mono\` font-family by default. + 2. Use the user's configured \`mono\` font-feature-settings by default. + 3. Use the user's configured \`mono\` font-variation-settings by default. + 4. Correct the odd \`em\` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: --theme( + --default-mono-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ); /* 1 */ + font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */ + font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */ + font-size: 1em; /* 4 */ +} + +/* + Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* + Prevent \`sub\` and \`sup\` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* + 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) + 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) + 3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* + Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring:where(:not(iframe)) { + outline: auto; +} + +/* + Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* + Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* + Make lists unstyled by default. +*/ + +ol, +ul, +menu { + list-style: none; +} + +/* + 1. Make replaced elements \`display: block\` by default. (https://github.com/mozdevs/cssremedy/issues/14) + 2. Add \`vertical-align: middle\` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* + Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* + 1. Inherit font styles in all browsers. + 2. Remove border radius in all browsers. + 3. Remove background color in all browsers. + 4. Ensure consistent opacity for disabled states in all browsers. +*/ + +button, +input, +select, +optgroup, +textarea, +::file-selector-button { + font: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + letter-spacing: inherit; /* 1 */ + color: inherit; /* 1 */ + border-radius: 0; /* 2 */ + background-color: transparent; /* 3 */ + opacity: 1; /* 4 */ +} + +/* + Restore default font weight. +*/ + +:where(select:is([multiple], [size])) optgroup { + font-weight: bolder; +} + +/* + Restore indentation. +*/ + +:where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; +} + +/* + Restore space after button. +*/ + +::file-selector-button { + margin-inline-end: 4px; +} + +/* + Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +*/ + +::placeholder { + opacity: 1; +} + +/* + Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not + crash when using \`color-mix(\u2026)\` with \`currentcolor\`. (https://github.com/tailwindlabs/tailwindcss/issues/17194) +*/ + +@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or + (contain-intrinsic-size: 1px) /* Safari 17+ */ { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } +} + +/* + Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* + Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* + 1. Ensure date/time inputs have the same height when empty in iOS Safari. + 2. Ensure text alignment can be changed on date/time inputs in iOS Safari. +*/ + +::-webkit-date-and-time-value { + min-height: 1lh; /* 1 */ + text-align: inherit; /* 2 */ +} + +/* + Prevent height from changing on date/time inputs in macOS Safari when the input is set to \`display: block\`. +*/ + +::-webkit-datetime-edit { + display: inline-flex; +} + +/* + Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers. +*/ + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-datetime-edit, +::-webkit-datetime-edit-year-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-minute-field, +::-webkit-datetime-edit-second-field, +::-webkit-datetime-edit-millisecond-field, +::-webkit-datetime-edit-meridiem-field { + padding-block: 0; +} + +/* + Center dropdown marker shown on inputs with paired \`\`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499) +*/ + +::-webkit-calendar-picker-indicator { + line-height: 1; +} + +/* + Remove the additional \`:invalid\` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* + Correct the inability to style the border radius in iOS Safari. +*/ + +button, +input:where([type='button'], [type='reset'], [type='submit']), +::file-selector-button { + appearance: button; +} + +/* + Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* + Make elements with the HTML hidden attribute stay hidden by default. +*/ + +[hidden]:where(:not([hidden='until-found'])) { + display: none !important; +} +`;var Jo=`@theme default { + --font-sans: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, + sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + + --color-red-50: oklch(97.1% 0.013 17.38); + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-200: oklch(88.5% 0.062 18.334); + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-red-800: oklch(44.4% 0.177 26.899); + --color-red-900: oklch(39.6% 0.141 25.723); + --color-red-950: oklch(25.8% 0.092 26.042); + + --color-orange-50: oklch(98% 0.016 73.684); + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-orange-200: oklch(90.1% 0.076 70.697); + --color-orange-300: oklch(83.7% 0.128 66.29); + --color-orange-400: oklch(75% 0.183 55.934); + --color-orange-500: oklch(70.5% 0.213 47.604); + --color-orange-600: oklch(64.6% 0.222 41.116); + --color-orange-700: oklch(55.3% 0.195 38.402); + --color-orange-800: oklch(47% 0.157 37.304); + --color-orange-900: oklch(40.8% 0.123 38.172); + --color-orange-950: oklch(26.6% 0.079 36.259); + + --color-amber-50: oklch(98.7% 0.022 95.277); + --color-amber-100: oklch(96.2% 0.059 95.617); + --color-amber-200: oklch(92.4% 0.12 95.746); + --color-amber-300: oklch(87.9% 0.169 91.605); + --color-amber-400: oklch(82.8% 0.189 84.429); + --color-amber-500: oklch(76.9% 0.188 70.08); + --color-amber-600: oklch(66.6% 0.179 58.318); + --color-amber-700: oklch(55.5% 0.163 48.998); + --color-amber-800: oklch(47.3% 0.137 46.201); + --color-amber-900: oklch(41.4% 0.112 45.904); + --color-amber-950: oklch(27.9% 0.077 45.635); + + --color-yellow-50: oklch(98.7% 0.026 102.212); + --color-yellow-100: oklch(97.3% 0.071 103.193); + --color-yellow-200: oklch(94.5% 0.129 101.54); + --color-yellow-300: oklch(90.5% 0.182 98.111); + --color-yellow-400: oklch(85.2% 0.199 91.936); + --color-yellow-500: oklch(79.5% 0.184 86.047); + --color-yellow-600: oklch(68.1% 0.162 75.834); + --color-yellow-700: oklch(55.4% 0.135 66.442); + --color-yellow-800: oklch(47.6% 0.114 61.907); + --color-yellow-900: oklch(42.1% 0.095 57.708); + --color-yellow-950: oklch(28.6% 0.066 53.813); + + --color-lime-50: oklch(98.6% 0.031 120.757); + --color-lime-100: oklch(96.7% 0.067 122.328); + --color-lime-200: oklch(93.8% 0.127 124.321); + --color-lime-300: oklch(89.7% 0.196 126.665); + --color-lime-400: oklch(84.1% 0.238 128.85); + --color-lime-500: oklch(76.8% 0.233 130.85); + --color-lime-600: oklch(64.8% 0.2 131.684); + --color-lime-700: oklch(53.2% 0.157 131.589); + --color-lime-800: oklch(45.3% 0.124 130.933); + --color-lime-900: oklch(40.5% 0.101 131.063); + --color-lime-950: oklch(27.4% 0.072 132.109); + + --color-green-50: oklch(98.2% 0.018 155.826); + --color-green-100: oklch(96.2% 0.044 156.743); + --color-green-200: oklch(92.5% 0.084 155.995); + --color-green-300: oklch(87.1% 0.15 154.449); + --color-green-400: oklch(79.2% 0.209 151.711); + --color-green-500: oklch(72.3% 0.219 149.579); + --color-green-600: oklch(62.7% 0.194 149.214); + --color-green-700: oklch(52.7% 0.154 150.069); + --color-green-800: oklch(44.8% 0.119 151.328); + --color-green-900: oklch(39.3% 0.095 152.535); + --color-green-950: oklch(26.6% 0.065 152.934); + + --color-emerald-50: oklch(97.9% 0.021 166.113); + --color-emerald-100: oklch(95% 0.052 163.051); + --color-emerald-200: oklch(90.5% 0.093 164.15); + --color-emerald-300: oklch(84.5% 0.143 164.978); + --color-emerald-400: oklch(76.5% 0.177 163.223); + --color-emerald-500: oklch(69.6% 0.17 162.48); + --color-emerald-600: oklch(59.6% 0.145 163.225); + --color-emerald-700: oklch(50.8% 0.118 165.612); + --color-emerald-800: oklch(43.2% 0.095 166.913); + --color-emerald-900: oklch(37.8% 0.077 168.94); + --color-emerald-950: oklch(26.2% 0.051 172.552); + + --color-teal-50: oklch(98.4% 0.014 180.72); + --color-teal-100: oklch(95.3% 0.051 180.801); + --color-teal-200: oklch(91% 0.096 180.426); + --color-teal-300: oklch(85.5% 0.138 181.071); + --color-teal-400: oklch(77.7% 0.152 181.912); + --color-teal-500: oklch(70.4% 0.14 182.503); + --color-teal-600: oklch(60% 0.118 184.704); + --color-teal-700: oklch(51.1% 0.096 186.391); + --color-teal-800: oklch(43.7% 0.078 188.216); + --color-teal-900: oklch(38.6% 0.063 188.416); + --color-teal-950: oklch(27.7% 0.046 192.524); + + --color-cyan-50: oklch(98.4% 0.019 200.873); + --color-cyan-100: oklch(95.6% 0.045 203.388); + --color-cyan-200: oklch(91.7% 0.08 205.041); + --color-cyan-300: oklch(86.5% 0.127 207.078); + --color-cyan-400: oklch(78.9% 0.154 211.53); + --color-cyan-500: oklch(71.5% 0.143 215.221); + --color-cyan-600: oklch(60.9% 0.126 221.723); + --color-cyan-700: oklch(52% 0.105 223.128); + --color-cyan-800: oklch(45% 0.085 224.283); + --color-cyan-900: oklch(39.8% 0.07 227.392); + --color-cyan-950: oklch(30.2% 0.056 229.695); + + --color-sky-50: oklch(97.7% 0.013 236.62); + --color-sky-100: oklch(95.1% 0.026 236.824); + --color-sky-200: oklch(90.1% 0.058 230.902); + --color-sky-300: oklch(82.8% 0.111 230.318); + --color-sky-400: oklch(74.6% 0.16 232.661); + --color-sky-500: oklch(68.5% 0.169 237.323); + --color-sky-600: oklch(58.8% 0.158 241.966); + --color-sky-700: oklch(50% 0.134 242.749); + --color-sky-800: oklch(44.3% 0.11 240.79); + --color-sky-900: oklch(39.1% 0.09 240.876); + --color-sky-950: oklch(29.3% 0.066 243.157); + + --color-blue-50: oklch(97% 0.014 254.604); + --color-blue-100: oklch(93.2% 0.032 255.585); + --color-blue-200: oklch(88.2% 0.059 254.128); + --color-blue-300: oklch(80.9% 0.105 251.813); + --color-blue-400: oklch(70.7% 0.165 254.624); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-blue-600: oklch(54.6% 0.245 262.881); + --color-blue-700: oklch(48.8% 0.243 264.376); + --color-blue-800: oklch(42.4% 0.199 265.638); + --color-blue-900: oklch(37.9% 0.146 265.522); + --color-blue-950: oklch(28.2% 0.091 267.935); + + --color-indigo-50: oklch(96.2% 0.018 272.314); + --color-indigo-100: oklch(93% 0.034 272.788); + --color-indigo-200: oklch(87% 0.065 274.039); + --color-indigo-300: oklch(78.5% 0.115 274.713); + --color-indigo-400: oklch(67.3% 0.182 276.935); + --color-indigo-500: oklch(58.5% 0.233 277.117); + --color-indigo-600: oklch(51.1% 0.262 276.966); + --color-indigo-700: oklch(45.7% 0.24 277.023); + --color-indigo-800: oklch(39.8% 0.195 277.366); + --color-indigo-900: oklch(35.9% 0.144 278.697); + --color-indigo-950: oklch(25.7% 0.09 281.288); + + --color-violet-50: oklch(96.9% 0.016 293.756); + --color-violet-100: oklch(94.3% 0.029 294.588); + --color-violet-200: oklch(89.4% 0.057 293.283); + --color-violet-300: oklch(81.1% 0.111 293.571); + --color-violet-400: oklch(70.2% 0.183 293.541); + --color-violet-500: oklch(60.6% 0.25 292.717); + --color-violet-600: oklch(54.1% 0.281 293.009); + --color-violet-700: oklch(49.1% 0.27 292.581); + --color-violet-800: oklch(43.2% 0.232 292.759); + --color-violet-900: oklch(38% 0.189 293.745); + --color-violet-950: oklch(28.3% 0.141 291.089); + + --color-purple-50: oklch(97.7% 0.014 308.299); + --color-purple-100: oklch(94.6% 0.033 307.174); + --color-purple-200: oklch(90.2% 0.063 306.703); + --color-purple-300: oklch(82.7% 0.119 306.383); + --color-purple-400: oklch(71.4% 0.203 305.504); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-purple-600: oklch(55.8% 0.288 302.321); + --color-purple-700: oklch(49.6% 0.265 301.924); + --color-purple-800: oklch(43.8% 0.218 303.724); + --color-purple-900: oklch(38.1% 0.176 304.987); + --color-purple-950: oklch(29.1% 0.149 302.717); + + --color-fuchsia-50: oklch(97.7% 0.017 320.058); + --color-fuchsia-100: oklch(95.2% 0.037 318.852); + --color-fuchsia-200: oklch(90.3% 0.076 319.62); + --color-fuchsia-300: oklch(83.3% 0.145 321.434); + --color-fuchsia-400: oklch(74% 0.238 322.16); + --color-fuchsia-500: oklch(66.7% 0.295 322.15); + --color-fuchsia-600: oklch(59.1% 0.293 322.896); + --color-fuchsia-700: oklch(51.8% 0.253 323.949); + --color-fuchsia-800: oklch(45.2% 0.211 324.591); + --color-fuchsia-900: oklch(40.1% 0.17 325.612); + --color-fuchsia-950: oklch(29.3% 0.136 325.661); + + --color-pink-50: oklch(97.1% 0.014 343.198); + --color-pink-100: oklch(94.8% 0.028 342.258); + --color-pink-200: oklch(89.9% 0.061 343.231); + --color-pink-300: oklch(82.3% 0.12 346.018); + --color-pink-400: oklch(71.8% 0.202 349.761); + --color-pink-500: oklch(65.6% 0.241 354.308); + --color-pink-600: oklch(59.2% 0.249 0.584); + --color-pink-700: oklch(52.5% 0.223 3.958); + --color-pink-800: oklch(45.9% 0.187 3.815); + --color-pink-900: oklch(40.8% 0.153 2.432); + --color-pink-950: oklch(28.4% 0.109 3.907); + + --color-rose-50: oklch(96.9% 0.015 12.422); + --color-rose-100: oklch(94.1% 0.03 12.58); + --color-rose-200: oklch(89.2% 0.058 10.001); + --color-rose-300: oklch(81% 0.117 11.638); + --color-rose-400: oklch(71.2% 0.194 13.428); + --color-rose-500: oklch(64.5% 0.246 16.439); + --color-rose-600: oklch(58.6% 0.253 17.585); + --color-rose-700: oklch(51.4% 0.222 16.935); + --color-rose-800: oklch(45.5% 0.188 13.697); + --color-rose-900: oklch(41% 0.159 10.272); + --color-rose-950: oklch(27.1% 0.105 12.094); + + --color-slate-50: oklch(98.4% 0.003 247.858); + --color-slate-100: oklch(96.8% 0.007 247.896); + --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-300: oklch(86.9% 0.022 252.894); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-slate-950: oklch(12.9% 0.042 264.695); + + --color-gray-50: oklch(98.5% 0.002 247.839); + --color-gray-100: oklch(96.7% 0.003 264.542); + --color-gray-200: oklch(92.8% 0.006 264.531); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-gray-500: oklch(55.1% 0.027 264.364); + --color-gray-600: oklch(44.6% 0.03 256.802); + --color-gray-700: oklch(37.3% 0.034 259.733); + --color-gray-800: oklch(27.8% 0.033 256.848); + --color-gray-900: oklch(21% 0.034 264.665); + --color-gray-950: oklch(13% 0.028 261.692); + + --color-zinc-50: oklch(98.5% 0 none); + --color-zinc-100: oklch(96.7% 0.001 286.375); + --color-zinc-200: oklch(92% 0.004 286.32); + --color-zinc-300: oklch(87.1% 0.006 286.286); + --color-zinc-400: oklch(70.5% 0.015 286.067); + --color-zinc-500: oklch(55.2% 0.016 285.938); + --color-zinc-600: oklch(44.2% 0.017 285.786); + --color-zinc-700: oklch(37% 0.013 285.805); + --color-zinc-800: oklch(27.4% 0.006 286.033); + --color-zinc-900: oklch(21% 0.006 285.885); + --color-zinc-950: oklch(14.1% 0.005 285.823); + + --color-neutral-50: oklch(98.5% 0 none); + --color-neutral-100: oklch(97% 0 none); + --color-neutral-200: oklch(92.2% 0 none); + --color-neutral-300: oklch(87% 0 none); + --color-neutral-400: oklch(70.8% 0 none); + --color-neutral-500: oklch(55.6% 0 none); + --color-neutral-600: oklch(43.9% 0 none); + --color-neutral-700: oklch(37.1% 0 none); + --color-neutral-800: oklch(26.9% 0 none); + --color-neutral-900: oklch(20.5% 0 none); + --color-neutral-950: oklch(14.5% 0 none); + + --color-stone-50: oklch(98.5% 0.001 106.423); + --color-stone-100: oklch(97% 0.001 106.424); + --color-stone-200: oklch(92.3% 0.003 48.717); + --color-stone-300: oklch(86.9% 0.005 56.366); + --color-stone-400: oklch(70.9% 0.01 56.259); + --color-stone-500: oklch(55.3% 0.013 58.071); + --color-stone-600: oklch(44.4% 0.011 73.639); + --color-stone-700: oklch(37.4% 0.01 67.558); + --color-stone-800: oklch(26.8% 0.007 34.298); + --color-stone-900: oklch(21.6% 0.006 56.043); + --color-stone-950: oklch(14.7% 0.004 49.25); + + --color-mauve-50: oklch(98.5% 0 none); + --color-mauve-100: oklch(96% 0.003 325.6); + --color-mauve-200: oklch(92.2% 0.005 325.62); + --color-mauve-300: oklch(86.5% 0.012 325.68); + --color-mauve-400: oklch(71.1% 0.019 323.02); + --color-mauve-500: oklch(54.2% 0.034 322.5); + --color-mauve-600: oklch(43.5% 0.029 321.78); + --color-mauve-700: oklch(36.4% 0.029 323.89); + --color-mauve-800: oklch(26.3% 0.024 320.12); + --color-mauve-900: oklch(21.2% 0.019 322.12); + --color-mauve-950: oklch(14.5% 0.008 326); + + --color-olive-50: oklch(98.8% 0.003 106.5); + --color-olive-100: oklch(96.6% 0.005 106.5); + --color-olive-200: oklch(93% 0.007 106.5); + --color-olive-300: oklch(88% 0.011 106.6); + --color-olive-400: oklch(73.7% 0.021 106.9); + --color-olive-500: oklch(58% 0.031 107.3); + --color-olive-600: oklch(46.6% 0.025 107.3); + --color-olive-700: oklch(39.4% 0.023 107.4); + --color-olive-800: oklch(28.6% 0.016 107.4); + --color-olive-900: oklch(22.8% 0.013 107.4); + --color-olive-950: oklch(15.3% 0.006 107.1); + + --color-mist-50: oklch(98.7% 0.002 197.1); + --color-mist-100: oklch(96.3% 0.002 197.1); + --color-mist-200: oklch(92.5% 0.005 214.3); + --color-mist-300: oklch(87.2% 0.007 219.6); + --color-mist-400: oklch(72.3% 0.014 214.4); + --color-mist-500: oklch(56% 0.021 213.5); + --color-mist-600: oklch(45% 0.017 213.2); + --color-mist-700: oklch(37.8% 0.015 216); + --color-mist-800: oklch(27.5% 0.011 216.9); + --color-mist-900: oklch(21.8% 0.008 223.9); + --color-mist-950: oklch(14.8% 0.004 228.8); + + --color-taupe-50: oklch(98.6% 0.002 67.8); + --color-taupe-100: oklch(96% 0.002 17.2); + --color-taupe-200: oklch(92.2% 0.005 34.3); + --color-taupe-300: oklch(86.8% 0.007 39.5); + --color-taupe-400: oklch(71.4% 0.014 41.2); + --color-taupe-500: oklch(54.7% 0.021 43.1); + --color-taupe-600: oklch(43.8% 0.017 39.3); + --color-taupe-700: oklch(36.7% 0.016 35.7); + --color-taupe-800: oklch(26.8% 0.011 36.5); + --color-taupe-900: oklch(21.4% 0.009 43.1); + --color-taupe-950: oklch(14.7% 0.004 49.3); + + --color-black: #000; + --color-white: #fff; + + --spacing: 0.25rem; + + --breakpoint-sm: 40rem; + --breakpoint-md: 48rem; + --breakpoint-lg: 64rem; + --breakpoint-xl: 80rem; + --breakpoint-2xl: 96rem; + + --container-3xs: 16rem; + --container-2xs: 18rem; + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --container-3xl: 48rem; + --container-4xl: 56rem; + --container-5xl: 64rem; + --container-6xl: 72rem; + --container-7xl: 80rem; + + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-6xl: 3.75rem; + --text-6xl--line-height: 1; + --text-7xl: 4.5rem; + --text-7xl--line-height: 1; + --text-8xl: 6rem; + --text-8xl--line-height: 1; + --text-9xl: 8rem; + --text-9xl--line-height: 1; + + --font-weight-thin: 100; + --font-weight-extralight: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --font-weight-extrabold: 800; + --font-weight-black: 900; + + --tracking-tighter: -0.05em; + --tracking-tight: -0.025em; + --tracking-normal: 0em; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --tracking-widest: 0.1em; + + --leading-tight: 1.25; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --leading-loose: 2; + + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --radius-3xl: 1.5rem; + --radius-4xl: 2rem; + + --shadow-2xs: 0 1px rgb(0 0 0 / 0.05); + --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05); + --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05); + --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05); + + --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05); + --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15); + --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12); + --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15); + --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1); + --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15); + + --text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15); + --text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2); + --text-shadow-sm: + 0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075); + --text-shadow-md: + 0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1); + --text-shadow-lg: + 0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1); + + --ease-in: cubic-bezier(0.4, 0, 1, 1); + --ease-out: cubic-bezier(0, 0, 0.2, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + + --animate-spin: spin 1s linear infinite; + --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-bounce: bounce 1s infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } + + @keyframes ping { + 75%, + 100% { + transform: scale(2); + opacity: 0; + } + } + + @keyframes pulse { + 50% { + opacity: 0.5; + } + } + + @keyframes bounce { + 0%, + 100% { + transform: translateY(-25%); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + + 50% { + transform: none; + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + } + + --blur-xs: 4px; + --blur-sm: 8px; + --blur-md: 12px; + --blur-lg: 16px; + --blur-xl: 24px; + --blur-2xl: 40px; + --blur-3xl: 64px; + + --perspective-dramatic: 100px; + --perspective-near: 300px; + --perspective-normal: 500px; + --perspective-midrange: 800px; + --perspective-distant: 1200px; + + --aspect-video: 16 / 9; + + --default-transition-duration: 150ms; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: --theme(--font-sans, initial); + --default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial); + --default-font-variation-settings: --theme(--font-sans--font-variation-settings, initial); + --default-mono-font-family: --theme(--font-mono, initial); + --default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings, initial); + --default-mono-font-variation-settings: --theme(--font-mono--font-variation-settings, initial); +} + +/* Deprecated */ +@theme default inline reference { + --blur: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); + --drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06); + --radius: 0.25rem; + --max-width-prose: 65ch; +} +`;var Xo=`@tailwind utilities; +`;var Xe={index:Yo,preflight:Qo,theme:Jo,utilities:Xo};var Tt=class{start(r){performance.mark(`${r} (start)`)}end(r,o){performance.mark(`${r} (end)`),performance.measure(r,{start:`${r} (start)`,end:`${r} (end)`,detail:o})}hit(r,o){performance.mark(r,{detail:o})}error(r){throw performance.mark("(error)",{detail:{error:`${r}`}}),r}};var ti="text/tailwindcss",$t,cr=new Set,ur="",fr=document.createElement("style"),ei=Promise.resolve(),cl=1,ie=new Tt;async function fl(){ie.start("Create compiler"),ie.start("Reading Stylesheets");let e=document.querySelectorAll(`style[type="${ti}"]`),r="";for(let o of e)ri(o),r+=o.textContent+` +`;if(r.includes("@import")||(r=`@import "tailwindcss";${r}`),ie.end("Reading Stylesheets",{size:r.length,changed:ur!==r}),ur!==r){ur=r,ie.start("Compile CSS");try{$t=await Zo(r,{base:"/",loadStylesheet:pl,loadModule:dl})}finally{ie.end("Compile CSS"),ie.end("Create compiler")}cr.clear()}}async function pl(e,r){function o(){if(e==="tailwindcss")return{path:"virtual:tailwindcss/index.css",base:r,content:Xe.index};if(e==="tailwindcss/preflight"||e==="tailwindcss/preflight.css"||e==="./preflight.css")return{path:"virtual:tailwindcss/preflight.css",base:r,content:Xe.preflight};if(e==="tailwindcss/theme"||e==="tailwindcss/theme.css"||e==="./theme.css")return{path:"virtual:tailwindcss/theme.css",base:r,content:Xe.theme};if(e==="tailwindcss/utilities"||e==="tailwindcss/utilities.css"||e==="./utilities.css")return{path:"virtual:tailwindcss/utilities.css",base:r,content:Xe.utilities};throw new Error(`The browser build does not support @import for "${e}"`)}try{let t=o();return ie.hit("Loaded stylesheet",{id:e,base:r,size:t.content.length}),t}catch(t){throw ie.hit("Failed to load stylesheet",{id:e,base:r,error:t.message??t}),t}}async function dl(){throw new Error("The browser build does not support plugins or config files.")}async function ml(e){if(!$t)return;let r=new Set;ie.start("Collect classes");for(let o of document.querySelectorAll("[class]"))for(let t of o.classList)cr.has(t)||(cr.add(t),r.add(t));ie.end("Collect classes",{count:r.size}),!(r.size===0&&e==="incremental")&&(ie.start("Build utilities"),fr.textContent=$t.build(Array.from(r)),ie.end("Build utilities"))}function Et(e){async function r(){if(!$t&&e!=="full")return;let o=cl++;ie.start(`Build #${o} (${e})`),e==="full"&&await fl(),ie.start("Build"),await ml(e),ie.end("Build"),ie.end(`Build #${o} (${e})`)}ei=ei.then(r).catch(o=>ie.error(o))}var hl=new MutationObserver(()=>Et("full"));function ri(e){hl.observe(e,{attributes:!0,attributeFilter:["type"],characterData:!0,subtree:!0,childList:!0})}new MutationObserver(e=>{let r=0,o=0;for(let t of e){for(let l of t.addedNodes)l.nodeType===Node.ELEMENT_NODE&&l.tagName==="STYLE"&&l.getAttribute("type")===ti&&(ri(l),r++);for(let l of t.addedNodes)l.nodeType===1&&l!==fr&&o++;t.type==="attributes"&&o++}if(r>0)return Et("full");if(o>0)return Et("incremental")}).observe(document.documentElement,{attributes:!0,attributeFilter:["class"],childList:!0,subtree:!0});Et("full");document.head.append(fr);})(); diff --git a/Software/web-server/templates/base.html b/Software/web-server/templates/base.html index a269721e..79d34992 100644 --- a/Software/web-server/templates/base.html +++ b/Software/web-server/templates/base.html @@ -6,9 +6,9 @@ {% block title %}PiTrac Launch Monitor{% endblock %} - - - + + + {% block extra_css %}{% endblock %} @@ -123,9 +123,9 @@

              Strobe Calibration Required

              - - - + + + {% block extra_scripts %}{% endblock %} diff --git a/Software/web-server/templates/calibration.html b/Software/web-server/templates/calibration.html index c12cf4fa..3cdc2d40 100644 --- a/Software/web-server/templates/calibration.html +++ b/Software/web-server/templates/calibration.html @@ -4,7 +4,7 @@ {% block title %}PiTrac Calibration{% endblock %} {% block extra_css %} - + {% endblock %} {% block content %} @@ -523,6 +523,6 @@

              Current Calibration Data

              - + + {% endblock %} diff --git a/Software/web-server/templates/config.html b/Software/web-server/templates/config.html index 3cbc61c5..ec5b9fe3 100644 --- a/Software/web-server/templates/config.html +++ b/Software/web-server/templates/config.html @@ -3,7 +3,7 @@ {% block title %}PiTrac Configuration{% endblock %} {% block extra_css %} - + {% endblock %} {% block content %} @@ -80,5 +80,5 @@

              Confirm

              {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/Software/web-server/templates/dashboard.html b/Software/web-server/templates/dashboard.html index e0849b7e..9d96ad7f 100644 --- a/Software/web-server/templates/dashboard.html +++ b/Software/web-server/templates/dashboard.html @@ -4,7 +4,7 @@ {% block title %}PiTrac Launch Monitor{% endblock %} {% block extra_css %} - + {% endblock %} {% block content %} @@ -64,5 +64,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/Software/web-server/templates/history.html b/Software/web-server/templates/history.html index 1868fedf..6a4ded4a 100644 --- a/Software/web-server/templates/history.html +++ b/Software/web-server/templates/history.html @@ -53,5 +53,5 @@

              Shot Detail {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/Software/web-server/templates/logs.html b/Software/web-server/templates/logs.html index cc286ee7..ac8128d9 100644 --- a/Software/web-server/templates/logs.html +++ b/Software/web-server/templates/logs.html @@ -3,7 +3,7 @@ {% block title %}PiTrac Logs{% endblock %} {% block extra_css %} - + {% endblock %} {% block content %} @@ -76,5 +76,5 @@ {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/Software/web-server/templates/testing.html b/Software/web-server/templates/testing.html index 6624523a..ad641020 100644 --- a/Software/web-server/templates/testing.html +++ b/Software/web-server/templates/testing.html @@ -4,7 +4,7 @@ {% block title %}Testing Tools - PiTrac{% endblock %} {% block extra_css %} - + {% endblock %} {% block content %} @@ -97,5 +97,5 @@

              Test Result

              {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/Software/web-server/templates/update.html b/Software/web-server/templates/update.html index 6b0a3508..79ba623c 100644 --- a/Software/web-server/templates/update.html +++ b/Software/web-server/templates/update.html @@ -4,7 +4,7 @@ {% block title %}System Update - PiTrac{% endblock %} {% block extra_css %} - + {% endblock %} {% block content %} @@ -84,5 +84,5 @@

              Build Output

              {% endblock %} {% block extra_scripts %} - + {% endblock %} diff --git a/Software/web-server/tests/test_smoke.py b/Software/web-server/tests/test_smoke.py index c8d80282..fc4e5dcd 100644 --- a/Software/web-server/tests/test_smoke.py +++ b/Software/web-server/tests/test_smoke.py @@ -217,3 +217,11 @@ def test_server_port_defaults_to_8080(monkeypatch): import constants importlib.reload(constants) assert constants.SERVER_PORT == 8080 + + +def test_pages_use_vendored_assets_with_version(client): + html = client.get("/").text + assert "cdn.jsdelivr.net" not in html + assert "/static/vendor/daisyui-5.7.47.css?v=" in html + assert "/static/js/common.js?v=" in html + assert client.get("/static/vendor/daisyui-5.7.47.css").status_code == 200 From a99137239c1878c898a29c6826603d903bd19a99 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 12 Sep 2026 21:35:56 -0400 Subject: [PATCH 126/156] shared helpers, one navbar with a real status pill, and a usable sims drawer --- Software/web-server/server.py | 11 +- Software/web-server/static/css/pitrac.css | 56 +++-- Software/web-server/static/js/common.js | 233 +++++++++++------- Software/web-server/static/js/dashboard.js | 5 - Software/web-server/static/js/sims.js | 131 +++++----- Software/web-server/templates/_macros.html | 27 +- Software/web-server/templates/base.html | 117 ++++----- .../web-server/templates/calibration.html | 26 +- Software/web-server/templates/dashboard.html | 2 +- Software/web-server/templates/testing.html | 1 - Software/web-server/templates/update.html | 4 +- Software/web-server/tests/test_sims_api.py | 10 + 12 files changed, 354 insertions(+), 269 deletions(-) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index abc41f8c..a6030d7f 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -289,19 +289,19 @@ async def get_sims() -> Dict[str, Any]: return {"sims": self.sim_manager.status()} @self.app.post("/api/sims/{name}/connect") - async def connect_sim(name: str) -> Dict[str, Any]: + async def connect_sim(name: str): try: await self.sim_manager.connect(name) except KeyError: - raise HTTPException(status_code=404, detail=f"Unknown sim: {name}") + return JSONResponse(status_code=404, content={"error": f"Unknown sim: {name}"}) return {"sims": self.sim_manager.status()} @self.app.post("/api/sims/{name}/disconnect") - async def disconnect_sim(name: str) -> Dict[str, Any]: + async def disconnect_sim(name: str): try: await self.sim_manager.disconnect(name) except KeyError: - raise HTTPException(status_code=404, detail=f"Unknown sim: {name}") + return JSONResponse(status_code=404, content={"error": f"Unknown sim: {name}"}) return {"sims": self.sim_manager.status()} @self.app.websocket("/ws/sims") @@ -396,7 +396,8 @@ async def internal_config() -> Dict[str, Any]: async def health_check() -> Dict[str, Union[str, bool, int]]: pitrac_running = False try: - result = subprocess.run( + result = await asyncio.to_thread( + subprocess.run, ["pgrep", "-f", "pitrac_lm"], capture_output=True, text=True, diff --git a/Software/web-server/static/css/pitrac.css b/Software/web-server/static/css/pitrac.css index 5bb4cda7..ce548246 100644 --- a/Software/web-server/static/css/pitrac.css +++ b/Software/web-server/static/css/pitrac.css @@ -55,22 +55,44 @@ html[data-theme="light"] { .lucide.icon-lg { width: 48px; height: 48px; } .lucide.icon-menu { width: 20px; height: 20px; } -/* Golf ball status dots — can't do this with daisyUI */ -.status-dot { - width: 0.75rem; - height: 0.75rem; - background-color: oklch(70% 0.14 160); - mask: url('/static/golf_ball.svg') center / contain no-repeat; - -webkit-mask: url('/static/golf_ball.svg') center / contain no-repeat; - filter: drop-shadow(0 0 3px oklch(70% 0.14 160)); - transition: all 0.3s; - cursor: help; +.status-pill { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.5rem; + white-space: nowrap; + background: var(--color-base-300); + color: var(--color-base-content); } +.status-pill::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + border-radius: 9999px; + background: color-mix(in oklab, var(--color-base-content) 35%, transparent); +} +.status-pill.is-running { background: color-mix(in oklab, var(--color-success) 20%, var(--color-base-300)); } +.status-pill.is-running::before { background: var(--color-success); } +.status-pill.is-offline { background: color-mix(in oklab, var(--color-error) 20%, var(--color-base-300)); } +.status-pill.is-offline::before { background: var(--color-error); } -.status-dot.disconnected { - background-color: oklch(65% 0.19 25); - filter: drop-shadow(0 0 3px oklch(65% 0.19 25)); - animation: pulse 2s infinite; +/* Log and command output panel */ +.terminal { + background: var(--color-neutral); + color: var(--color-neutral-content); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-box, 0.5rem); + padding: 1rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow-y: auto; } /* Logo invert for dark mode */ @@ -88,9 +110,6 @@ html[data-theme="light"] { border-color: transparent; } -/* Control button color overrides — daisyUI btn-success/error/warning handle most of it, - but we keep these classes for JS compatibility */ -.control-btn.loading { animation: pulse 1s infinite; } .control-btn svg { width: 16px; height: 16px; } /* Animations */ @@ -110,6 +129,3 @@ html[data-theme="light"] { } .animate-fade-in { animation: fadeIn 0.4s ease-out; } - -/* Mono text helper */ -.mono { font-family: ui-monospace, monospace; } diff --git a/Software/web-server/static/js/common.js b/Software/web-server/static/js/common.js index 7b6afb3d..e1a189ff 100644 --- a/Software/web-server/static/js/common.js +++ b/Software/web-server/static/js/common.js @@ -1,11 +1,102 @@ // Common functionality for all PiTrac pages -/* global showStatusMessage */ -/* exported setTheme, controlPiTrac, requireStrobeSafe */ +/* exported setTheme, controlPiTrac, requireStrobeSafe, escapeHtml, toast, confirmDialog, api, openSocket, onPiTracStatus, formatNumber */ if (typeof lucide !== 'undefined') { lucide.createIcons(); } +// -- Helpers -- + +function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = value == null ? '' : String(value); + return div.innerHTML; +} + +function formatNumber(value, decimals) { + const n = Number(value); + return value == null || Number.isNaN(n) ? '--' : n.toFixed(decimals); +} + +async function api(path, { method = 'GET', body } = {}) { + const res = await fetch(path, { + method, + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { data = null; } + if (!res.ok) throw new Error((data && (data.error || data.message)) || `${res.status} ${res.statusText}`); + return data; +} + +function openSocket(path, onMessage, { onOpen, onClose } = {}) { + let ws = null; + let timer = null; + let stopped = false; + const url = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}${path}`; + const connect = () => { + if (stopped || (ws && ws.readyState <= WebSocket.OPEN)) return; + clearTimeout(timer); + timer = null; + ws = new WebSocket(url); + ws.onopen = () => onOpen && onOpen(); + ws.onmessage = (e) => onMessage(JSON.parse(e.data)); + ws.onclose = () => { + if (onClose) onClose(); + if (!stopped && !timer) timer = setTimeout(connect, 3000); + }; + ws.onerror = () => ws.close(); + }; + document.addEventListener('visibilitychange', () => { if (!document.hidden) connect(); }); + connect(); + return { close() { stopped = true; clearTimeout(timer); if (ws) ws.close(); } }; +} + +function toast(message, type = 'info', { sticky = type === 'error', actionHref = null, actionLabel = null, onAction = null } = {}) { + const region = document.getElementById('toast-region'); + if (!region) return; + const el = document.createElement('div'); + el.className = `alert alert-${type} max-w-sm whitespace-normal`; + const text = document.createElement('span'); + text.textContent = message; + el.append(text); + if (actionLabel && (actionHref || onAction)) { + const action = document.createElement(actionHref ? 'a' : 'button'); + action.className = 'btn btn-sm whitespace-nowrap'; + action.textContent = actionLabel; + if (actionHref) action.href = actionHref; + if (onAction) action.addEventListener('click', () => { el.remove(); onAction(); }); + el.append(action); + } + if (sticky) { + const close = document.createElement('button'); + close.className = 'btn btn-sm btn-ghost btn-square'; + close.setAttribute('aria-label', 'Dismiss'); + close.textContent = '✕'; + close.addEventListener('click', () => el.remove()); + el.append(close); + } else { + setTimeout(() => el.remove(), 4000); + } + region.append(el); +} + +function confirmDialog({ title, body, confirmLabel = 'Confirm', danger = false }) { + const dialog = document.getElementById('confirm-dialog'); + document.getElementById('confirm-dialog-title').textContent = title; + document.getElementById('confirm-dialog-body').textContent = body; + const ok = document.getElementById('confirm-dialog-ok'); + ok.textContent = confirmLabel; + ok.className = `btn ${danger ? 'btn-error' : 'btn-primary'}`; + dialog.returnValue = ''; + dialog.showModal(); + return new Promise((resolve) => { + dialog.addEventListener('close', () => resolve(dialog.returnValue === 'confirm'), { once: true }); + }); +} + // -- Theme -- let currentTheme = 'system'; @@ -15,18 +106,8 @@ function getSystemTheme() { } function applyTheme(theme) { - const root = document.documentElement; - document.querySelectorAll('.theme-btn').forEach(b => b.classList.remove('active')); - - if (theme === 'system') { - root.setAttribute('data-theme', getSystemTheme()); - const btn = document.querySelector('.theme-btn[data-theme="system"]'); - if (btn) btn.classList.add('active'); - } else { - root.setAttribute('data-theme', theme); - const btn = document.querySelector(`.theme-btn[data-theme="${theme}"]`); - if (btn) btn.classList.add('active'); - } + document.documentElement.setAttribute('data-theme', theme === 'system' ? getSystemTheme() : theme); + document.querySelectorAll('.theme-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === theme)); } function setTheme(theme) { @@ -36,7 +117,8 @@ function setTheme(theme) { } function initTheme() { - applyTheme(localStorage.getItem('pitrac-theme') || 'system'); + currentTheme = localStorage.getItem('pitrac-theme') || 'system'; + applyTheme(currentTheme); } window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { @@ -45,110 +127,90 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () // -- PiTrac Controls -- -function getButtons(action) { - return ['desktop', 'mobile'] - .map(s => document.getElementById(`pitrac-${action}-btn-${s}`)) - .filter(Boolean); -} - // Every other status from /api/pitrac/{start,stop,restart} is a failure or a refusal const PITRAC_OK_STATUSES = ['started', 'already_running', 'stopped', 'not_running']; +let pitracActionInFlight = false; + async function controlPiTrac(action) { if ((action === 'start' || action === 'restart') && !(await requireStrobeSafe())) {return;} + pitracActionInFlight = true; document.querySelectorAll('.control-btn').forEach(b => { b.disabled = true; }); - getButtons(action).forEach(b => b.classList.add('loading')); + const spinner = document.createElement('span'); + spinner.className = 'loading loading-spinner loading-xs'; + const btn = document.getElementById(`pitrac-${action}-btn`); + if (btn) btn.prepend(spinner); try { - const res = await fetch(`/api/pitrac/${action}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || `Failed to ${action} PiTrac`); - } - const data = await res.json(); + const data = await api(`/api/pitrac/${action}`, { method: 'POST' }); if (!PITRAC_OK_STATUSES.includes(data.status)) throw new Error(data.message || `Failed to ${action} PiTrac`); - if (typeof showStatusMessage === 'function') showStatusMessage(data.message, 'success'); - setTimeout(() => { if (typeof checkSystemStatus === 'function') checkSystemStatus(); }, 2000); + toast(data.message, 'success'); } catch (err) { - console.error(`Error ${action}ing PiTrac:`, err); - if (typeof showStatusMessage === 'function') { - showStatusMessage(err.message, 'error'); - } else { - alert(err.message); - } + toast(err.message, 'error', { actionHref: '/logs', actionLabel: 'Open logs' }); } finally { - getButtons(action).forEach(b => b.classList.remove('loading')); + spinner.remove(); setTimeout(() => { - if (typeof checkPiTracStatus === 'function') { - checkPiTracStatus(); - } else { - document.querySelectorAll('.control-btn').forEach(b => { b.disabled = false; }); - } + pitracActionInFlight = false; + window.checkPiTracStatus(); }, 1000); } } function updatePiTracButtons(isRunning) { - const show = (btns) => btns.forEach(b => { b.style.display = ''; b.disabled = false; }); - const hide = (btns) => btns.forEach(b => { b.style.display = 'none'; }); - - if (isRunning) { - hide(getButtons('start')); - show(getButtons('stop')); - show(getButtons('restart')); - } else { - show(getButtons('start')); - hide(getButtons('stop')); - hide(getButtons('restart')); - } + const set = (id, visible) => { + const el = document.getElementById(id); + if (el) el.classList.toggle('hidden', !visible); + }; + set('pitrac-start-btn', !isRunning); + set('pitrac-stop-btn', isRunning); + set('pitrac-restart-item', isRunning); + document.querySelectorAll('.control-btn').forEach(b => { b.disabled = false; }); } // -- Status Polling -- -async function checkSystemStatus() { - try { - const res = await fetch('/health'); - if (res.ok) return await res.json(); - } catch (e) { - console.error('Error checking system status:', e); - } - return null; +const piTracStatusSubscribers = []; + +function onPiTracStatus(fn) { + piTracStatusSubscribers.push(fn); +} + +function updateStatusPill({ is_running, pid, offline }) { + const pill = document.getElementById('pitrac-status-pill'); + if (!pill) return; + const state = offline ? 'offline' : is_running ? 'running' : 'stopped'; + const label = { running: 'Running', stopped: 'Stopped', offline: 'Offline' }[state]; + pill.className = `status-pill is-${state}`; + if (pill.textContent !== label) pill.textContent = label; + pill.title = offline ? 'Cannot reach the PiTrac web server' + : is_running ? `PiTrac is running (PID ${pid})` : 'PiTrac is stopped'; } async function checkPiTracStatus() { + let status; try { - const res = await fetch('/api/pitrac/status'); - const status = await res.json(); - updatePiTracButtons(status.is_running); - - const dot = document.getElementById('pitrac-status-dot'); - if (dot) { - dot.classList.toggle('connected', !!status.pid); - dot.classList.toggle('disconnected', !status.pid); - dot.title = status.pid ? `PiTrac Running (PID: ${status.pid})` : 'PiTrac Stopped'; - } - return status.is_running; - } catch (e) { - console.error('Failed to check PiTrac status:', e); - return false; + const data = await api('/api/pitrac/status'); + status = { is_running: !!data.is_running, pid: data.pid ?? null, offline: false }; + } catch { + status = { is_running: false, pid: null, offline: true }; } + updateStatusPill(status); + if (!pitracActionInFlight && !status.offline) updatePiTracButtons(status.is_running); + piTracStatusSubscribers.forEach(fn => fn(status)); + return status.is_running; } // -- Strobe Safety -- async function requireStrobeSafe() { try { - const res = await fetch('/api/strobe-safety'); - const data = await res.json(); + const data = await api('/api/strobe-safety'); if (data.safe) return true; showStrobeSafetyModal(data.reason || ''); return false; } catch { - showStrobeSafetyModal('Could not verify strobe safety — check server connection.'); + showStrobeSafetyModal('Could not verify strobe safety. Check the connection to the PiTrac web server.'); return false; } } @@ -164,11 +226,14 @@ function showStrobeSafetyModal(reason) { // -- Init -- +document.addEventListener('click', (e) => { + const menu = document.getElementById('nav-dropdown'); + if (menu && menu.open && (!menu.contains(e.target) || e.target.closest('ul a, ul button'))) menu.open = false; +}); + document.addEventListener('DOMContentLoaded', () => { initTheme(); - checkSystemStatus(); checkPiTracStatus(); - setInterval(checkSystemStatus, 5000); // Indirect call so page-specific wrappers (e.g. dashboard.js) take effect. setInterval(() => window.checkPiTracStatus(), 5000); }); diff --git a/Software/web-server/static/js/dashboard.js b/Software/web-server/static/js/dashboard.js index 51748612..cc12b171 100644 --- a/Software/web-server/static/js/dashboard.js +++ b/Software/web-server/static/js/dashboard.js @@ -7,10 +7,6 @@ function connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${protocol}//${window.location.host}/ws`); - ws.onopen = () => { - document.getElementById('ws-status-dot').classList.remove('disconnected'); - }; - ws.onmessage = (event) => { const data = JSON.parse(event.data); if (!piTracRunning) { @@ -26,7 +22,6 @@ function connectWebSocket() { }; ws.onclose = () => { - document.getElementById('ws-status-dot').classList.add('disconnected'); setTimeout(connectWebSocket, 3000); }; diff --git a/Software/web-server/static/js/sims.js b/Software/web-server/static/js/sims.js index 51ea8368..5a1f2d0f 100644 --- a/Software/web-server/static/js/sims.js +++ b/Software/web-server/static/js/sims.js @@ -1,98 +1,87 @@ // sims.js - simulators drawer: live status over /ws/sims, connect/disconnect controls +/* global api, toast, escapeHtml, openSocket */ (function () { - let ws = null; + const STATUS = { + connected: { label: 'Connected', dot: 'bg-success' }, + connecting: { label: 'Connecting', dot: 'bg-warning' }, + error: { label: "Can't connect", dot: 'bg-error' }, + off: { label: 'Not connected', dot: 'bg-base-content/30' }, + }; + const OPEN_KEY = 'pitrac-sims-open'; - function statusColor(status) { - if (status === 'connected') return 'var(--color-success, #16a34a)'; - if (status === 'connecting' || status === 'error') return 'var(--color-warning, #d97706)'; - return 'var(--color-neutral, #9ca3af)'; + function statusOf(name) { + return STATUS[name] || STATUS.off; } function aggregate(sims) { - if (sims.some((s) => s.status === 'connected')) return 'connected'; - if (sims.some((s) => s.status === 'connecting' || s.status === 'error')) return 'connecting'; - return 'off'; + return ['error', 'connecting', 'connected'].find((st) => sims.some((s) => s.status === st)) || 'off'; } - function render(sims) { + function rowHtml(s) { + const st = statusOf(s.status); + const action = s.status === 'connected' ? 'disconnect' : 'connect'; + const label = s.status === 'connecting' ? 'Connecting' : s.status === 'connected' ? 'Disconnect' : 'Connect'; + const button = s.target + ? `` + : ''; + const detail = s.target ? s.detail : s.detail || 'No host set in Configuration.'; + const detailClass = s.status === 'error' || !s.target ? 'text-error' : 'opacity-70'; + return ` +
              +
              + + ${escapeHtml(s.display_name || s.name)} + ${st.label} + ${button} +
              + ${s.target ? `
              ${escapeHtml(s.target)}
              ` : ''} + ${detail && detail !== s.target ? `
              ${escapeHtml(detail)}
              ` : ''} +
              `; + } + + function render(data) { + if (data.type !== 'sim_status') return; + const sims = data.sims || []; + const dot = document.getElementById('sims-status-dot'); - if (dot) dot.style.background = statusColor(aggregate(sims)); + if (dot) dot.className = `w-2 h-2 rounded-full ${statusOf(aggregate(sims)).dot}`; const list = document.getElementById('sims-list'); if (!list) return; - if (!sims.length) { - list.innerHTML = - '

              No simulators enabled. Enable one in Configuration.

              '; - return; - } - list.innerHTML = sims - .map((s) => { - const connected = s.status === 'connected'; - const connecting = s.status === 'connecting'; - const action = connected ? 'disconnect' : 'connect'; - const label = connecting ? 'Connecting…' : connected ? 'Disconnect' : 'Connect'; - const disabled = connecting ? 'disabled' : ''; - return ` -
              -
              - - ${s.display_name || s.name} - ${s.status} -
              -
              ${s.target || ''} ${s.detail ? '· ' + s.detail : ''}
              - -
              `; - }) - .join(''); - - list.querySelectorAll('button[data-sim]').forEach((btn) => { - btn.addEventListener('click', async () => { - btn.disabled = true; - try { - const r = await fetch(`/api/sims/${btn.dataset.sim}/${btn.dataset.action}`, { - method: 'POST', - }); - const data = await r.json(); - if (data.sims) render(data.sims); - } catch (e) { - console.error('sim action failed', e); - } finally { - btn.disabled = false; - } - }); - }); + list.innerHTML = sims.length + ? sims.map(rowHtml).join('') + : '

              No simulator is enabled yet. Set one up in Configuration

              '; } - async function loadInitial() { + async function onListClick(e) { + const btn = e.target.closest('button[data-sim]'); + if (!btn) return; + btn.disabled = true; try { - const r = await fetch('/api/sims'); - const data = await r.json(); - render(data.sims || []); - } catch (e) { - console.error('failed to load sims', e); + const data = await api(`/api/sims/${btn.dataset.sim}/${btn.dataset.action}`, { method: 'POST' }); + render({ type: 'sim_status', sims: data.sims }); + } catch (err) { + toast(err.message, 'error'); + btn.disabled = false; } } - function connectWs() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${protocol}//${window.location.host}/ws/sims`); - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.type === 'sim_status') render(data.sims || []); - }; - ws.onclose = () => setTimeout(connectWs, 3000); - ws.onerror = () => { - if (ws) ws.close(); - }; + function setOpen(drawer, btn, open) { + drawer.classList.toggle('hidden', !open); + btn.setAttribute('aria-expanded', String(open)); + try { localStorage.setItem(OPEN_KEY, open ? '1' : ''); } catch { /* storage unavailable */ } } document.addEventListener('DOMContentLoaded', () => { const btn = document.getElementById('sims-nav-btn'); const drawer = document.getElementById('sims-drawer'); if (btn && drawer) { - btn.addEventListener('click', () => drawer.classList.toggle('hidden')); + let open = false; + try { open = localStorage.getItem(OPEN_KEY) === '1'; } catch { /* storage unavailable */ } + setOpen(drawer, btn, open); + btn.addEventListener('click', () => setOpen(drawer, btn, drawer.classList.contains('hidden'))); } - loadInitial(); - connectWs(); + document.getElementById('sims-list')?.addEventListener('click', onListClick); + openSocket('/ws/sims', render); }); })(); diff --git a/Software/web-server/templates/_macros.html b/Software/web-server/templates/_macros.html index fc60c354..2ef5f729 100644 --- a/Software/web-server/templates/_macros.html +++ b/Software/web-server/templates/_macros.html @@ -29,8 +29,8 @@ {% endmacro %} -{% macro pitrac_btn(action, icon, label, suffix, extra_class="") %} - {% endmacro %} -{% macro nav_item(href, icon, label) %} -
            • {{ label }}
            • +{% macro nav_item(href, icon, label, active=false, li_class="") %} + {{ label }} +{% endmacro %} + +{% macro page_header(icon, title, subtitle="") %} +
              +
              + +
              +

              {{ title }}

              + {% if subtitle %}

              {{ subtitle }}

              {% endif %} +
              +
              + {% if caller is defined %}
              {{ caller() }}
              {% endif %} +
              {% endmacro %} {% macro status_row(label, id, value="--", extra_class="") %} @@ -50,9 +63,3 @@ {% endmacro %} -{% macro result_item(label, id, value="--") %} -
              - {{ label }} - {{ value }} -
              -{% endmacro %} diff --git a/Software/web-server/templates/base.html b/Software/web-server/templates/base.html index 79d34992..c0223ffe 100644 --- a/Software/web-server/templates/base.html +++ b/Software/web-server/templates/base.html @@ -1,10 +1,20 @@ {% from "_macros.html" import pitrac_btn, nav_item %} +{% set nav_pages = [ + ("/", "home", "Dashboard"), + ("/config", "settings", "Configuration"), + ("/calibration", "crosshair", "Calibration"), + ("/history", "list", "Shot History"), + ("/logs", "file-text", "Logs"), + ("/testing", "flask-conical", "Testing"), + ("/update", "refresh-cw", "Update"), +] %} - + {% block title %}PiTrac Launch Monitor{% endblock %} + @@ -18,73 +28,51 @@
              - {% block content %}{% endblock %}
              - +
              + + + + + + diff --git a/Software/web-server/templates/calibration.html b/Software/web-server/templates/calibration.html index 3cdc2d40..5fad3dbe 100644 --- a/Software/web-server/templates/calibration.html +++ b/Software/web-server/templates/calibration.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import result_item, status_row %} +{% from "_macros.html" import status_row %} {% block title %}PiTrac Calibration{% endblock %} @@ -88,7 +88,7 @@

              Live Feed

              Camera feed

              Connecting to camera...

              @@ -384,10 +384,10 @@

              Calibration Results:

              Camera 1 - Tee Camera

              - {{ result_item("Status", "camera1-result-status") }} - {{ result_item("Completion Method", "camera1-completion-method") }} - {{ result_item("Focal Length", "camera1-focal") }} - {{ result_item("Camera Angles", "camera1-angles") }} + {{ status_row("Status", "camera1-result-status") }} + {{ status_row("Completion Method", "camera1-completion-method") }} + {{ status_row("Focal Length", "camera1-focal") }} + {{ status_row("Camera Angles", "camera1-angles") }}
              @@ -397,10 +397,10 @@

              Camera 1 - Tee Camera

              Camera 2 - Flight Camera

              - {{ result_item("Status", "camera2-result-status") }} - {{ result_item("Completion Method", "camera2-completion-method") }} - {{ result_item("Focal Length", "camera2-focal") }} - {{ result_item("Camera Angles", "camera2-angles") }} + {{ status_row("Status", "camera2-result-status") }} + {{ status_row("Completion Method", "camera2-completion-method") }} + {{ status_row("Focal Length", "camera2-focal") }} + {{ status_row("Camera Angles", "camera2-angles") }}
              @@ -492,9 +492,9 @@

              Calibration Progress

              Calibration Result

              - {{ result_item("DAC Setting", "strobe-result-dac") }} - {{ result_item("LED Current", "strobe-result-current") }} - {{ result_item("LDO Voltage", "strobe-result-ldo") }} + {{ status_row("DAC Setting", "strobe-result-dac") }} + {{ status_row("LED Current", "strobe-result-current") }} + {{ status_row("LDO Voltage", "strobe-result-ldo") }}
              diff --git a/Software/web-server/templates/dashboard.html b/Software/web-server/templates/dashboard.html index 9d96ad7f..b32a0c88 100644 --- a/Software/web-server/templates/dashboard.html +++ b/Software/web-server/templates/dashboard.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import result_item, status_row, metric_card %} +{% from "_macros.html" import metric_card %} {% block title %}PiTrac Launch Monitor{% endblock %} diff --git a/Software/web-server/templates/testing.html b/Software/web-server/templates/testing.html index ad641020..c12e4ad4 100644 --- a/Software/web-server/templates/testing.html +++ b/Software/web-server/templates/testing.html @@ -1,5 +1,4 @@ {% extends "base.html" %} -{% from "_macros.html" import result_item, status_row %} {% block title %}Testing Tools - PiTrac{% endblock %} diff --git a/Software/web-server/templates/update.html b/Software/web-server/templates/update.html index 79ba623c..7d9e35eb 100644 --- a/Software/web-server/templates/update.html +++ b/Software/web-server/templates/update.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% from "_macros.html" import result_item, status_row %} +{% from "_macros.html" import status_row %} {% block title %}System Update - PiTrac{% endblock %} @@ -21,7 +21,7 @@

              {{ status_row("Current Branch", "currentBranch") }} - {{ status_row("Current Commit", "currentHash", extra_class="mono") }} + {{ status_row("Current Commit", "currentHash", extra_class="font-mono") }} {{ status_row("Last Build", "lastBuild") }} {{ status_row("Last Check", "lastCheck") }}

    Setting
    ${displayName}${unsavedTag}
    ${key}
    @@ -1060,7 +1058,7 @@ async function showDiff() { ${userVal}
    - +