alchemist-nrel 0.3.1__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. alchemist_core/__init__.py +2 -2
  2. alchemist_core/acquisition/botorch_acquisition.py +83 -126
  3. alchemist_core/data/experiment_manager.py +181 -12
  4. alchemist_core/models/botorch_model.py +292 -63
  5. alchemist_core/models/sklearn_model.py +145 -13
  6. alchemist_core/session.py +3330 -31
  7. alchemist_core/utils/__init__.py +3 -1
  8. alchemist_core/utils/acquisition_utils.py +60 -0
  9. alchemist_core/visualization/__init__.py +45 -0
  10. alchemist_core/visualization/helpers.py +130 -0
  11. alchemist_core/visualization/plots.py +1449 -0
  12. {alchemist_nrel-0.3.1.dist-info → alchemist_nrel-0.3.2.dist-info}/METADATA +13 -13
  13. {alchemist_nrel-0.3.1.dist-info → alchemist_nrel-0.3.2.dist-info}/RECORD +31 -26
  14. {alchemist_nrel-0.3.1.dist-info → alchemist_nrel-0.3.2.dist-info}/WHEEL +1 -1
  15. api/main.py +1 -1
  16. api/models/requests.py +52 -0
  17. api/models/responses.py +79 -2
  18. api/routers/experiments.py +333 -8
  19. api/routers/sessions.py +84 -9
  20. api/routers/visualizations.py +6 -4
  21. api/routers/websocket.py +2 -2
  22. api/services/session_store.py +295 -71
  23. api/static/assets/index-B6Cf6s_b.css +1 -0
  24. api/static/assets/{index-DWfIKU9j.js → index-B7njvc9r.js} +201 -196
  25. api/static/index.html +2 -2
  26. ui/gpr_panel.py +11 -5
  27. ui/target_column_dialog.py +299 -0
  28. ui/ui.py +52 -5
  29. api/static/assets/index-sMIa_1hV.css +0 -1
  30. {alchemist_nrel-0.3.1.dist-info → alchemist_nrel-0.3.2.dist-info}/entry_points.txt +0 -0
  31. {alchemist_nrel-0.3.1.dist-info → alchemist_nrel-0.3.2.dist-info}/licenses/LICENSE +0 -0
  32. {alchemist_nrel-0.3.1.dist-info → alchemist_nrel-0.3.2.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,11 @@
1
1
  """
2
2
  Session Store - Session management with disk persistence.
3
3
 
4
- Stores OptimizationSession instances with TTL and automatic cleanup.
5
- Sessions are persisted to disk as JSON to survive server restarts.
4
+ Stores OptimizationSession instances with recovery backup system.
5
+ Sessions persist in RAM until explicitly saved by user.
6
6
  """
7
7
 
8
- from typing import Dict, Optional
8
+ from typing import Dict, Optional, List
9
9
  from datetime import datetime, timedelta
10
10
  import uuid
11
11
  from alchemist_core.session import OptimizationSession
@@ -35,20 +35,21 @@ class SessionStore:
35
35
  Initialize session store.
36
36
 
37
37
  Args:
38
- default_ttl_hours: Default time-to-live for sessions in hours
38
+ default_ttl_hours: Legacy parameter (kept for compatibility, not used for TTL)
39
39
  persist_dir: Directory to persist sessions (None = memory only)
40
40
  """
41
41
  self._sessions: Dict[str, Dict] = {}
42
- self.default_ttl = timedelta(hours=default_ttl_hours)
43
42
  self.persist_dir = Path(persist_dir) if persist_dir else Path("cache/sessions")
43
+ self.recovery_dir = Path("cache/recovery")
44
44
 
45
- # Create persistence directory
45
+ # Create directories
46
46
  if self.persist_dir:
47
47
  self.persist_dir.mkdir(parents=True, exist_ok=True)
48
- # Load existing sessions from disk
49
- self._load_from_disk()
48
+ self.recovery_dir.mkdir(parents=True, exist_ok=True)
50
49
 
51
- logger.info(f"SessionStore initialized with TTL={default_ttl_hours}h, persist_dir={self.persist_dir}")
50
+ # Note: No longer auto-loading sessions on startup
51
+ # Sessions are created on-demand or loaded explicitly by user
52
+ logger.info(f"SessionStore initialized with persist_dir={self.persist_dir}, recovery_dir={self.recovery_dir}")
52
53
 
53
54
  def _get_session_file(self, session_id: str) -> Path:
54
55
  """Get path to session file."""
@@ -71,8 +72,7 @@ class SessionStore:
71
72
  # Store metadata alongside session
72
73
  metadata = {
73
74
  "created_at": session_data["created_at"].isoformat(),
74
- "last_accessed": session_data["last_accessed"].isoformat(),
75
- "expires_at": session_data["expires_at"].isoformat()
75
+ "last_accessed": session_data["last_accessed"].isoformat()
76
76
  }
77
77
 
78
78
  # Load session JSON and add metadata
@@ -105,12 +105,7 @@ class SessionStore:
105
105
  # Extract metadata
106
106
  metadata = session_json.pop("_session_store_metadata", {})
107
107
 
108
- # Check if expired
109
- if metadata:
110
- expires_at = datetime.fromisoformat(metadata["expires_at"])
111
- if datetime.now() > expires_at:
112
- session_file.unlink() # Delete expired session file
113
- continue
108
+ # No longer check for expiration - TTL system removed
114
109
 
115
110
  # Write session data to temp file and load
116
111
  with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
@@ -125,7 +120,6 @@ class SessionStore:
125
120
  "session": session,
126
121
  "created_at": datetime.fromisoformat(metadata.get("created_at", datetime.now().isoformat())),
127
122
  "last_accessed": datetime.fromisoformat(metadata.get("last_accessed", datetime.now().isoformat())),
128
- "expires_at": datetime.fromisoformat(metadata.get("expires_at", (datetime.now() + self.default_ttl).isoformat())),
129
123
  "lock": threading.Lock()
130
124
  }
131
125
  loaded_count += 1
@@ -177,12 +171,11 @@ class SessionStore:
177
171
  "session": session,
178
172
  "created_at": datetime.now(),
179
173
  "last_accessed": datetime.now(),
180
- "expires_at": datetime.now() + self.default_ttl,
181
174
  "lock": threading.Lock()
182
175
  }
183
176
 
184
- # Persist to disk
185
- self._save_to_disk(session_id)
177
+ # Note: No automatic disk save on creation
178
+ # User will explicitly save when ready
186
179
 
187
180
  logger.info(f"Created session {session_id}")
188
181
  return session_id
@@ -195,11 +188,8 @@ class SessionStore:
195
188
  session_id: Session identifier
196
189
 
197
190
  Returns:
198
- OptimizationSession or None if not found/expired
191
+ OptimizationSession or None if not found
199
192
  """
200
- # Clean up expired sessions first
201
- self._cleanup_expired()
202
-
203
193
  if session_id not in self._sessions:
204
194
  logger.warning(f"Session {session_id} not found")
205
195
  return None
@@ -208,27 +198,12 @@ class SessionStore:
208
198
  lock = session_data.get("lock")
209
199
  if lock:
210
200
  with lock:
211
- # Check if expired
212
- if datetime.now() > session_data["expires_at"]:
213
- logger.info(f"Session {session_id} expired, removing")
214
- del self._sessions[session_id]
215
- return None
216
-
217
- # Update last accessed time
201
+ # Update last accessed time (no save to disk)
218
202
  session_data["last_accessed"] = datetime.now()
219
-
220
- # Save updated access time to disk
221
- self._save_to_disk(session_id)
222
-
223
203
  return session_data["session"]
224
204
  else:
225
205
  # Fallback (no lock present)
226
- if datetime.now() > session_data["expires_at"]:
227
- logger.info(f"Session {session_id} expired, removing")
228
- del self._sessions[session_id]
229
- return None
230
206
  session_data["last_accessed"] = datetime.now()
231
- self._save_to_disk(session_id)
232
207
  return session_data["session"]
233
208
 
234
209
  def delete(self, session_id: str) -> bool:
@@ -274,7 +249,6 @@ class SessionStore:
274
249
  "session_id": session_id,
275
250
  "created_at": session_data["created_at"].isoformat(),
276
251
  "last_accessed": session_data["last_accessed"].isoformat(),
277
- "expires_at": session_data["expires_at"].isoformat(),
278
252
  "search_space": session.get_search_space_summary(),
279
253
  "data": session.get_data_summary(),
280
254
  "model": session.get_model_summary()
@@ -282,52 +256,32 @@ class SessionStore:
282
256
 
283
257
  def extend_ttl(self, session_id: str, hours: int = None) -> bool:
284
258
  """
285
- Extend session TTL.
259
+ Legacy method - no longer used (sessions don't expire).
260
+ Kept for API compatibility.
286
261
 
287
262
  Args:
288
263
  session_id: Session identifier
289
- hours: Hours to extend (uses default if None)
264
+ hours: Ignored
290
265
 
291
266
  Returns:
292
- True if extended, False if session not found
267
+ True if session exists, False otherwise
293
268
  """
294
269
  if session_id not in self._sessions:
295
270
  return False
296
- lock = self._sessions[session_id].get("lock")
297
- if lock:
298
- with lock:
299
- extension = timedelta(hours=hours) if hours else self.default_ttl
300
- self._sessions[session_id]["expires_at"] = datetime.now() + extension
301
- self._save_to_disk(session_id)
302
- else:
303
- extension = timedelta(hours=hours) if hours else self.default_ttl
304
- self._sessions[session_id]["expires_at"] = datetime.now() + extension
305
- self._save_to_disk(session_id)
306
-
307
- logger.info(f"Extended TTL for session {session_id}")
271
+ logger.info(f"extend_ttl called for session {session_id} (no-op - TTL removed)")
308
272
  return True
309
273
 
310
274
  def _cleanup_expired(self):
311
- """Remove expired sessions."""
312
- now = datetime.now()
313
- expired = [
314
- sid for sid, data in self._sessions.items()
315
- if now > data["expires_at"]
316
- ]
317
-
318
- for sid in expired:
319
- del self._sessions[sid]
320
- self._delete_from_disk(sid)
321
- logger.info(f"Cleaned up expired session {sid}")
275
+ """Legacy method - no longer used (sessions don't expire)."""
276
+ # No-op: sessions no longer have TTL expiration
277
+ pass
322
278
 
323
279
  def count(self) -> int:
324
280
  """Get count of active sessions."""
325
- self._cleanup_expired()
326
281
  return len(self._sessions)
327
282
 
328
283
  def list_all(self) -> list:
329
284
  """Get list of all active session IDs."""
330
- self._cleanup_expired()
331
285
  return list(self._sessions.keys())
332
286
 
333
287
  def export_session(self, session_id: str) -> Optional[str]:
@@ -429,7 +383,6 @@ class SessionStore:
429
383
  "session": session,
430
384
  "created_at": datetime.now(),
431
385
  "last_accessed": datetime.now(),
432
- "expires_at": datetime.now() + self.default_ttl,
433
386
  "lock": threading.Lock()
434
387
  }
435
388
 
@@ -517,6 +470,277 @@ class SessionStore:
517
470
  "lock_token": None # Never expose token in status check
518
471
  }
519
472
 
473
+ # ============================================================
474
+ # Recovery / Backup System
475
+ # ============================================================
476
+
477
+ def _get_recovery_file(self, session_id: str) -> Path:
478
+ """Get path to recovery backup file."""
479
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
480
+ return self.recovery_dir / f"{session_id}_recovery_{timestamp}.json"
481
+
482
+ def save_recovery_backup(self, session_id: str) -> bool:
483
+ """
484
+ Save a recovery backup for crash protection.
485
+
486
+ This is called periodically (every 30s) from frontend to create
487
+ silent backups. User never sees these unless needed for recovery.
488
+
489
+ Args:
490
+ session_id: Session identifier
491
+
492
+ Returns:
493
+ True if successful, False otherwise
494
+ """
495
+ if session_id not in self._sessions:
496
+ return False
497
+
498
+ try:
499
+ lock = self._sessions[session_id].get("lock")
500
+
501
+ # Clean up old recovery files for this session first
502
+ self._cleanup_old_recovery_files(session_id)
503
+
504
+ recovery_file = self._get_recovery_file(session_id)
505
+
506
+ if lock:
507
+ with lock:
508
+ # Create temporary file
509
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
510
+ self._sessions[session_id]["session"].save_session(tmp.name)
511
+ temp_path = tmp.name
512
+
513
+ # Read and add recovery metadata
514
+ with open(temp_path, 'r') as f:
515
+ session_json = json.load(f)
516
+
517
+ session_json["_recovery_metadata"] = {
518
+ "session_id": session_id,
519
+ "backup_time": datetime.now().isoformat(),
520
+ "session_name": self._sessions[session_id]["session"].metadata.name
521
+ }
522
+
523
+ # Write to recovery file
524
+ with open(recovery_file, 'w') as f:
525
+ json.dump(session_json, f, indent=2)
526
+
527
+ # Clean up temp file
528
+ Path(temp_path).unlink()
529
+ else:
530
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
531
+ self._sessions[session_id]["session"].save_session(tmp.name)
532
+ temp_path = tmp.name
533
+
534
+ with open(temp_path, 'r') as f:
535
+ session_json = json.load(f)
536
+
537
+ session_json["_recovery_metadata"] = {
538
+ "session_id": session_id,
539
+ "backup_time": datetime.now().isoformat(),
540
+ "session_name": self._sessions[session_id]["session"].metadata.name
541
+ }
542
+
543
+ with open(recovery_file, 'w') as f:
544
+ json.dump(session_json, f, indent=2)
545
+
546
+ Path(temp_path).unlink()
547
+
548
+ logger.debug(f"Recovery backup saved for session {session_id}")
549
+ return True
550
+
551
+ except Exception as e:
552
+ logger.error(f"Failed to save recovery backup for {session_id}: {e}")
553
+ return False
554
+
555
+ def _cleanup_old_recovery_files(self, session_id: str, keep_newest: int = 1):
556
+ """
557
+ Clean up old recovery files for a session, keeping only the newest.
558
+
559
+ Args:
560
+ session_id: Session identifier
561
+ keep_newest: Number of newest files to keep (default 1)
562
+ """
563
+ if not self.recovery_dir.exists():
564
+ return
565
+
566
+ # Find all recovery files for this session
567
+ pattern = f"{session_id}_recovery_*.json"
568
+ recovery_files = sorted(self.recovery_dir.glob(pattern), key=lambda p: p.stat().st_mtime)
569
+
570
+ # Delete all but the newest
571
+ for old_file in recovery_files[:-keep_newest]:
572
+ try:
573
+ old_file.unlink()
574
+ logger.debug(f"Deleted old recovery file: {old_file.name}")
575
+ except Exception as e:
576
+ logger.warning(f"Failed to delete old recovery file {old_file}: {e}")
577
+
578
+ def clear_recovery_backup(self, session_id: str) -> bool:
579
+ """
580
+ Delete all recovery backups for a session.
581
+
582
+ Called after user successfully saves their session to their computer.
583
+
584
+ Args:
585
+ session_id: Session identifier
586
+
587
+ Returns:
588
+ True if any files were deleted
589
+ """
590
+ if not self.recovery_dir.exists():
591
+ return False
592
+
593
+ deleted = False
594
+ pattern = f"{session_id}_recovery_*.json"
595
+
596
+ for recovery_file in self.recovery_dir.glob(pattern):
597
+ try:
598
+ recovery_file.unlink()
599
+ logger.info(f"Deleted recovery backup: {recovery_file.name}")
600
+ deleted = True
601
+ except Exception as e:
602
+ logger.error(f"Failed to delete recovery file {recovery_file}: {e}")
603
+
604
+ return deleted
605
+
606
+ def list_recovery_sessions(self) -> List[Dict]:
607
+ """
608
+ List all available recovery sessions.
609
+
610
+ Returns list of recovery metadata for frontend to display.
611
+ """
612
+ if not self.recovery_dir.exists():
613
+ return []
614
+
615
+ recoveries = []
616
+
617
+ # Group by session_id (only show newest for each)
618
+ session_files = {}
619
+ for recovery_file in self.recovery_dir.glob("*_recovery_*.json"):
620
+ try:
621
+ # Extract session_id from filename
622
+ parts = recovery_file.stem.split("_recovery_")
623
+ if len(parts) != 2:
624
+ continue
625
+
626
+ session_id = parts[0]
627
+
628
+ # Keep only newest file per session
629
+ if session_id not in session_files:
630
+ session_files[session_id] = recovery_file
631
+ else:
632
+ # Compare modification times
633
+ if recovery_file.stat().st_mtime > session_files[session_id].stat().st_mtime:
634
+ session_files[session_id] = recovery_file
635
+ except Exception as e:
636
+ logger.warning(f"Error processing recovery file {recovery_file}: {e}")
637
+
638
+ # Load metadata from newest files
639
+ for session_id, recovery_file in session_files.items():
640
+ try:
641
+ with open(recovery_file, 'r') as f:
642
+ session_json = json.load(f)
643
+
644
+ metadata = session_json.get("_recovery_metadata", {})
645
+ session_meta = session_json.get("metadata", {})
646
+
647
+ # Get session statistics
648
+ experiments = session_json.get("experiments", {})
649
+ n_experiments = experiments.get("n_total", 0)
650
+
651
+ search_space = session_json.get("search_space", {})
652
+ n_variables = len(search_space.get("variables", []))
653
+
654
+ model_config = session_json.get("model_config", {})
655
+ model_trained = model_config is not None and len(model_config) > 0
656
+
657
+ recoveries.append({
658
+ "session_id": session_id,
659
+ "session_name": metadata.get("session_name", session_meta.get("name", "Untitled Session")),
660
+ "backup_time": metadata.get("backup_time", datetime.fromtimestamp(recovery_file.stat().st_mtime).isoformat()),
661
+ "n_variables": n_variables,
662
+ "n_experiments": n_experiments,
663
+ "model_trained": model_trained,
664
+ "file_path": str(recovery_file)
665
+ })
666
+ except Exception as e:
667
+ logger.error(f"Error reading recovery file {recovery_file}: {e}")
668
+
669
+ # Sort by backup time (newest first)
670
+ recoveries.sort(key=lambda x: x["backup_time"], reverse=True)
671
+
672
+ return recoveries
673
+
674
+ def restore_from_recovery(self, session_id: str) -> Optional[str]:
675
+ """
676
+ Restore a session from recovery backup.
677
+
678
+ Creates a new active session from the recovery file.
679
+
680
+ Args:
681
+ session_id: Original session ID to restore
682
+
683
+ Returns:
684
+ New session ID if successful, None otherwise
685
+ """
686
+ if not self.recovery_dir.exists():
687
+ return None
688
+
689
+ # Find newest recovery file for this session
690
+ pattern = f"{session_id}_recovery_*.json"
691
+ recovery_files = sorted(self.recovery_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
692
+
693
+ if not recovery_files:
694
+ logger.warning(f"No recovery files found for session {session_id}")
695
+ return None
696
+
697
+ recovery_file = recovery_files[0]
698
+
699
+ try:
700
+ # Read recovery file
701
+ with open(recovery_file, 'r') as f:
702
+ session_data = f.read()
703
+
704
+ # Import as new session (generates new ID)
705
+ new_session_id = self.import_session(session_data)
706
+
707
+ if new_session_id:
708
+ logger.info(f"Restored session {session_id} as new session {new_session_id}")
709
+ # Keep recovery file until user explicitly saves
710
+ return new_session_id
711
+
712
+ except Exception as e:
713
+ logger.error(f"Failed to restore from recovery {recovery_file}: {e}")
714
+
715
+ return None
716
+
717
+ def cleanup_old_recoveries(self, max_age_hours: int = 24):
718
+ """
719
+ Clean up recovery files older than specified hours.
720
+
721
+ Called periodically to prevent accumulation.
722
+
723
+ Args:
724
+ max_age_hours: Maximum age in hours (default 24)
725
+ """
726
+ if not self.recovery_dir.exists():
727
+ return
728
+
729
+ cutoff_time = datetime.now().timestamp() - (max_age_hours * 3600)
730
+ deleted_count = 0
731
+
732
+ for recovery_file in self.recovery_dir.glob("*_recovery_*.json"):
733
+ try:
734
+ if recovery_file.stat().st_mtime < cutoff_time:
735
+ recovery_file.unlink()
736
+ deleted_count += 1
737
+ logger.debug(f"Deleted old recovery file: {recovery_file.name}")
738
+ except Exception as e:
739
+ logger.warning(f"Failed to delete recovery file {recovery_file}: {e}")
740
+
741
+ if deleted_count > 0:
742
+ logger.info(f"Cleaned up {deleted_count} old recovery files")
743
+
520
744
 
521
745
  # Global session store instance
522
746
  session_store = SessionStore(default_ttl_hours=24)
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--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: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--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: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 98%;--foreground: 222.2 84% 4.9%;--card: 0 0% 100%;--card-foreground: 222.2 84% 4.9%;--popover: 0 0% 100%;--popover-foreground: 222.2 84% 4.9%;--primary: 217 91% 50%;--primary-foreground: 0 0% 100%;--secondary: 214 32% 88%;--secondary-foreground: 222.2 47.4% 11.2%;--muted: 210 40% 93%;--muted-foreground: 215.4 16.3% 40%;--accent: 210 40% 93%;--accent-foreground: 222.2 47.4% 11.2%;--destructive: 0 84.2% 50%;--destructive-foreground: 0 0% 100%;--border: 214.3 31.8% 85%;--input: 214.3 31.8% 85%;--ring: 217 91% 50%;--radius: .5rem}.dark{--background: 220 13% 13%;--foreground: 210 40% 98%;--card: 220 13% 16%;--card-foreground: 210 40% 98%;--popover: 220 13% 16%;--popover-foreground: 210 40% 98%;--primary: 211 100% 65%;--primary-foreground: 220 13% 13%;--secondary: 217.2 32.6% 20%;--secondary-foreground: 210 40% 98%;--muted: 220 13% 20%;--muted-foreground: 215 15% 65%;--accent: 220 13% 20%;--accent-foreground: 210 40% 98%;--destructive: 0 70% 55%;--destructive-foreground: 210 40% 98%;--border: 220 13% 24%;--input: 220 13% 24%;--ring: 211 100% 65%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));margin:0;min-height:100vh;font-feature-settings:"kern" 1,"liga" 1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}code,.tabular-nums{font-variant-numeric:tabular-nums;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.right-0{right:0}.top-0{top:0}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-span-3{grid-column:span 3 / span 3}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-8{margin-top:2rem;margin-bottom:2rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-0\.5{height:.125rem}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-96{height:24rem}.h-auto{height:auto}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-64{max-height:16rem}.max-h-\[120px\]{max-height:120px}.max-h-\[450px\]{max-height:450px}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0px}.min-h-\[550px\]{min-height:550px}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.w-\[320px\]{width:320px}.w-\[580px\]{width:580px}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-7xl{max-width:80rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-y-1\.5{row-gap:.375rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.625rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-500\/20{border-color:#f59e0b33}.border-blue-500\/20{border-color:#3b82f633}.border-blue-500\/30{border-color:#3b82f64d}.border-border{border-color:hsl(var(--border))}.border-border\/50{border-color:hsl(var(--border) / .5)}.border-destructive\/30{border-color:hsl(var(--destructive) / .3)}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1))}.border-gray-300{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1))}.border-green-500\/20{border-color:#22c55e33}.border-input{border-color:hsl(var(--input))}.border-muted{border-color:hsl(var(--muted))}.border-muted-foreground\/20{border-color:hsl(var(--muted-foreground) / .2)}.border-primary{border-color:hsl(var(--primary))}.border-transparent{border-color:transparent}.border-yellow-200{--tw-border-opacity: 1;border-color:rgb(254 240 138 / var(--tw-border-opacity, 1))}.bg-accent{background-color:hsl(var(--accent))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-background{background-color:hsl(var(--background))}.bg-black\/50{background-color:#00000080}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/5{background-color:#3b82f60d}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-card{background-color:hsl(var(--card))}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-muted{background-color:hsl(var(--muted))}.bg-muted\/20{background-color:hsl(var(--muted) / .2)}.bg-muted\/30{background-color:hsl(var(--muted) / .3)}.bg-muted\/50{background-color:hsl(var(--muted) / .5)}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/5{background-color:hsl(var(--primary) / .05)}.bg-secondary{background-color:hsl(var(--secondary))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-yellow-50{--tw-bg-opacity: 1;background-color:rgb(254 252 232 / var(--tw-bg-opacity, 1))}.bg-yellow-600{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pr-2{padding-right:.5rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-6xl{font-size:3.75rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-tight{line-height:1.25}.tracking-wide{letter-spacing:.025em}.text-accent-foreground{color:hsl(var(--accent-foreground))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-destructive{color:hsl(var(--destructive))}.text-foreground{color:hsl(var(--foreground))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-green-700{--tw-text-opacity: 1;color:rgb(21 128 61 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-muted-foreground\/60{color:hsl(var(--muted-foreground) / .6)}.text-orange-600{--tw-text-opacity: 1;color:rgb(234 88 12 / var(--tw-text-opacity, 1))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-purple-500{--tw-text-opacity: 1;color:rgb(168 85 247 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.text-yellow-600{--tw-text-opacity: 1;color:rgb(202 138 4 / var(--tw-text-opacity, 1))}.text-yellow-700{--tw-text-opacity: 1;color:rgb(161 98 7 / var(--tw-text-opacity, 1))}.text-yellow-800{--tw-text-opacity: 1;color:rgb(133 77 14 / var(--tw-text-opacity, 1))}.text-yellow-900{--tw-text-opacity: 1;color:rgb(113 63 18 / var(--tw-text-opacity, 1))}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.filter{filter: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)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:hsl(var(--muted-foreground) / .3);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:hsl(var(--muted-foreground) / .5)}*{scrollbar-width:thin;scrollbar-color:hsl(var(--muted-foreground) / .3) transparent}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:border-border:hover{border-color:hsl(var(--border))}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-accent\/50:hover{background-color:hsl(var(--accent) / .5)}.hover\:bg-accent\/80:hover{background-color:hsl(var(--accent) / .8)}.hover\:bg-blue-700:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.hover\:bg-destructive\/10:hover{background-color:hsl(var(--destructive) / .1)}.hover\:bg-gray-300:hover{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-50:hover{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.hover\:bg-green-700:hover{--tw-bg-opacity: 1;background-color:rgb(21 128 61 / var(--tw-bg-opacity, 1))}.hover\:bg-muted\/5:hover{background-color:hsl(var(--muted) / .05)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-muted\/80:hover{background-color:hsl(var(--muted) / .8)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:bg-secondary\/90:hover{background-color:hsl(var(--secondary) / .9)}.hover\:bg-yellow-700:hover{--tw-bg-opacity: 1;background-color:rgb(161 98 7 / var(--tw-bg-opacity, 1))}.hover\:text-destructive\/80:hover{color:hsl(var(--destructive) / .8)}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:text-gray-600:hover{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.hover\:text-primary\/80:hover{color:hsl(var(--primary) / .8)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.focus\:ring-primary\/50:focus{--tw-ring-color: hsl(var(--primary) / .5)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-gray-600:is(.dark *){--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.dark\:border-gray-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.dark\:border-yellow-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(133 77 14 / var(--tw-border-opacity, 1))}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.dark\:bg-yellow-900\/20:is(.dark *){background-color:#713f1233}.dark\:text-amber-400:is(.dark *){--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.dark\:text-amber-500:is(.dark *){--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.dark\:text-gray-200:is(.dark *){--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.dark\:text-gray-300:is(.dark *){--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.dark\:text-green-500:is(.dark *){--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.dark\:text-white:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.dark\:text-yellow-100:is(.dark *){--tw-text-opacity: 1;color:rgb(254 249 195 / var(--tw-text-opacity, 1))}.dark\:text-yellow-200:is(.dark *){--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.dark\:text-yellow-300:is(.dark *){--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.dark\:text-yellow-500:is(.dark *){--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.dark\:hover\:bg-gray-600:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.dark\:hover\:text-gray-300:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}