Kaynağa Gözat

Merge pull request #42 from maziggy/0.1.6b4

v0.1.6b4

### New Features

  # Printer Cards
  - Refactored AMS section for better visual grouping and spacing

### Bugfixes

  ### Printer Hour Counter
  - Fixed runtime_seconds not incrementing during prints
  - Bug fix: first timestamp was set but never committed

  ### Slicer Protocol
  - Add OS detection for slicer protocol handler
  - Windows: `bambustudio://`, macOS/Linux: `bambustudioopen://`
  - Updated all usages in Archives and Model Viewer
  - 
  ### Camera Popup Window
  - Auto-resize to fit video resolution on first open
  - Persist window size and position to localStorage
  - Restore saved window state for subsequent opens

  ### Maintenance Page
  - Improved duration display with better precision (weeks instead of imprecise months)
  - Large print hours now show readable units (e.g., 478h → 3w, 100h → 4d)
MartinNYHC 8 ay önce
ebeveyn
işleme
7e3fcec02d
38 değiştirilmiş dosya ile 3522 ekleme ve 509 silme
  1. 34 0
      CHANGELOG.md
  2. 1 0
      Dockerfile
  3. 1 1
      README.md
  4. 102 27
      backend/app/api/routes/cloud.py
  5. 31 3
      backend/app/api/routes/printers.py
  6. 432 67
      backend/app/api/routes/settings.py
  7. 18 8
      backend/app/core/config.py
  8. 12 3
      backend/app/main.py
  9. 4 1
      backend/app/schemas/printer.py
  10. 18 0
      backend/app/schemas/settings.py
  11. 39 31
      backend/app/services/bambu_mqtt.py
  12. 39 1
      backend/app/services/printer_manager.py
  13. 36 35
      backend/tests/integration/test_settings_api.py
  14. 1299 0
      frontend/mockups/ams-redesign.html
  15. 2 0
      frontend/src/__tests__/components/AMSHistoryModal.test.tsx
  16. 4 0
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  17. 2 0
      frontend/src/__tests__/pages/SystemInfoPage.test.tsx
  18. 16 2
      frontend/src/api/client.ts
  19. 2 2
      frontend/src/components/AMSHistoryModal.tsx
  20. 3 3
      frontend/src/components/AddExternalLinkModal.tsx
  21. 1 1
      frontend/src/components/Card.tsx
  22. 267 0
      frontend/src/components/FilamentHoverCard.tsx
  23. 10 10
      frontend/src/components/Layout.tsx
  24. 2 2
      frontend/src/components/ModelViewerModal.tsx
  25. 0 52
      frontend/src/components/ThemeContext.tsx
  26. 149 21
      frontend/src/contexts/ThemeContext.tsx
  27. 190 6
      frontend/src/index.css
  28. 7 6
      frontend/src/pages/ArchivesPage.tsx
  29. 59 0
      frontend/src/pages/CameraPage.tsx
  30. 2 2
      frontend/src/pages/ExternalLinkPage.tsx
  31. 13 3
      frontend/src/pages/MaintenancePage.tsx
  32. 527 217
      frontend/src/pages/PrintersPage.tsx
  33. 128 3
      frontend/src/pages/SettingsPage.tsx
  34. 70 0
      frontend/src/utils/slicer.ts
  35. 0 0
      static/assets/index-3umWYOC3.js
  36. 0 0
      static/assets/index-BuWV4aNb.css
  37. 0 0
      static/assets/index-CCbBv2VC.css
  38. 2 2
      static/index.html

+ 34 - 0
CHANGELOG.md

@@ -2,6 +2,40 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b4] - 2026-01-01
+
+### Added
+- **Camera popup window improvements**
+  - Auto-resize to fit video resolution on first open
+  - Persist window size and position to localStorage
+  - Restore saved window state for subsequent opens
+- **Slicer protocol handler** - OS detection for correct protocol (Windows: `bambustudio://`, macOS/Linux: `bambustudioopen://`)
+
+### Fixed
+- **Maintenance duration display** - Show weeks instead of imprecise months for better countdown precision
+- **Print hours display** - Convert large hour values to readable units (e.g., 478h → 3w, 100h → 4d)
+- **Printer hour counter** - Fixed runtime_seconds not incrementing during prints (first timestamp was set but never committed)
+
+### Changed
+- **Printer cards** - Refactored AMS section for better visual grouping and spacing
+
+## [0.1.6b3] - 2025-12-31
+
+### Added
+- **Customizable Theme System** - Comprehensive theme customization with independent settings for dark and light modes:
+  - **Style**: Classic (clean shadows), Glow (accent-colored glow effects), Vibrant (dramatic deep shadows)
+  - **Background**: Neutral, Warm, Cool (light mode) + OLED, Slate, Forest (dark mode only)
+  - **Accent Colors**: Green, Teal, Blue, Orange, Purple, Red
+  - All combinations work together (e.g., Glow style + Forest background + Teal accent)
+  - Settings sync across devices via database
+  - Live preview in Settings → Appearance
+
+### Fixed
+- **Printer hour counter** - Fixed bug in printer's hour counter display
+
+### Changed
+- **Sidebar power switch** - Added confirmation modal to sidebar's quick power switch
+
 ## [0.1.6b2] - 2025-12-29
 
 ### Added

+ 1 - 0
Dockerfile

@@ -37,6 +37,7 @@ RUN mkdir -p /app/data /app/logs
 # Environment variables
 ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
+ENV LOG_DIR=/app/logs
 
 EXPOSE 8000
 

+ 1 - 1
README.md

@@ -109,7 +109,7 @@
 </tr>
 </table>
 
-**Plus:** Dark/light theme • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
+**Plus:** Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
 
 ---
 

+ 102 - 27
backend/app/api/routes/cloud.py

@@ -5,33 +5,36 @@ Handles authentication and profile management with Bambu Cloud.
 """
 
 import json
+import logging
 from pathlib import Path
 from typing import Literal
 
-from fastapi import APIRouter, HTTPException, Depends
-from sqlalchemy.ext.asyncio import AsyncSession
+from fastapi import APIRouter, Body, Depends, HTTPException
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import get_db
 from backend.app.models.settings import Settings
-from backend.app.services.bambu_cloud import (
-    get_cloud_service,
-    BambuCloudError,
-    BambuCloudAuthError,
-)
 from backend.app.schemas.cloud import (
+    CloudAuthStatus,
+    CloudDevice,
     CloudLoginRequest,
-    CloudVerifyRequest,
     CloudLoginResponse,
-    CloudAuthStatus,
     CloudTokenRequest,
-    SlicerSettingsResponse,
+    CloudVerifyRequest,
     SlicerSetting,
-    CloudDevice,
     SlicerSettingCreate,
-    SlicerSettingUpdate,
     SlicerSettingDeleteResponse,
+    SlicerSettingsResponse,
+    SlicerSettingUpdate,
 )
+from backend.app.services.bambu_cloud import (
+    BambuCloudAuthError,
+    BambuCloudError,
+    get_cloud_service,
+)
+
+logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/cloud", tags=["cloud"])
 
@@ -43,9 +46,7 @@ CLOUD_EMAIL_KEY = "bambu_cloud_email"
 
 async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]:
     """Get stored cloud token and email from database."""
-    result = await db.execute(
-        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
-    )
+    result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
     settings = {s.key: s.value for s in result.scalars().all()}
     return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
 
@@ -64,9 +65,7 @@ async def store_token(db: AsyncSession, token: str, email: str) -> None:
 
 async def clear_token(db: AsyncSession) -> None:
     """Clear stored cloud token and email."""
-    result = await db.execute(
-        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
-    )
+    result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
     for setting in result.scalars().all():
         await db.delete(setting)
     await db.commit()
@@ -213,14 +212,16 @@ async def get_slicer_settings(
 
             parsed = []
             for s in all_settings:
-                parsed.append(SlicerSetting(
-                    setting_id=s.get("setting_id", s.get("id", "")),
-                    name=s.get("name", "Unknown"),
-                    type=our_type,
-                    version=s.get("version"),
-                    user_id=s.get("user_id"),
-                    updated_time=s.get("updated_time"),
-                ))
+                parsed.append(
+                    SlicerSetting(
+                        setting_id=s.get("setting_id", s.get("id", "")),
+                        name=s.get("name", "Unknown"),
+                        type=our_type,
+                        version=s.get("version"),
+                        user_id=s.get("user_id"),
+                        updated_time=s.get("updated_time"),
+                    )
+                )
             setattr(result, our_type, parsed)
 
         return result
@@ -258,6 +259,80 @@ async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)
         raise HTTPException(status_code=500, detail=str(e))
 
 
+# Cache for filament preset info (setting_id -> {name, k})
+_filament_cache: dict[str, dict] = {}
+_filament_cache_time: float = 0
+FILAMENT_CACHE_TTL = 300  # 5 minutes
+
+
+@router.post("/filament-info")
+async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
+    """
+    Get filament preset info (name and K value) for multiple setting IDs.
+
+    Used to enrich AMS tray tooltips with cloud preset data.
+    """
+    import time
+
+    logger.info(f"get_filament_info called with {len(setting_ids)} IDs: {setting_ids}")
+
+    global _filament_cache, _filament_cache_time
+
+    # Clear stale cache
+    if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:
+        _filament_cache = {}
+        _filament_cache_time = time.time()
+
+    token, _ = await get_stored_token(db)
+    if not token:
+        logger.info("get_filament_info: Not authenticated, returning empty")
+        # Return empty results if not authenticated (graceful degradation)
+        return {}
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        return {}
+
+    result = {}
+    for setting_id in setting_ids:
+        if not setting_id:
+            continue
+
+        # Check cache first
+        if setting_id in _filament_cache:
+            result[setting_id] = _filament_cache[setting_id]
+            continue
+
+        try:
+            data = await cloud.get_setting_detail(setting_id)
+            setting = data.get("setting", {})
+
+            # Extract name (e.g., "Bambu PLA Basic Jade White")
+            name = data.get("name", "")
+
+            # Extract K value (pressure_advance)
+            k_value = setting.get("pressure_advance")
+            if k_value is not None:
+                try:
+                    k_value = float(k_value)
+                except (ValueError, TypeError):
+                    k_value = None
+
+            info = {"name": name, "k": k_value}
+            _filament_cache[setting_id] = info
+            result[setting_id] = info
+
+        except Exception as e:
+            logger.warning(f"Failed to get cloud preset {setting_id}: {e}")
+            # Cache the failure to avoid repeated requests
+            _filament_cache[setting_id] = {"name": "", "k": None}
+            result[setting_id] = {"name": "", "k": None}
+
+    return result
+
+
 @router.get("/devices", response_model=list[CloudDevice])
 async def get_devices(db: AsyncSession = Depends(get_db)):
     """
@@ -425,7 +500,7 @@ def _load_fields(preset_type: str) -> dict:
     if not file_path.exists():
         raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
 
-    with open(file_path, "r") as f:
+    with open(file_path) as f:
         data = json.load(f)
 
     _fields_cache[preset_type] = data

+ 31 - 3
backend/app/api/routes/printers.py

@@ -169,6 +169,16 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
     ams_exists = False
     raw_data = state.raw_data or {}
 
+    # Build K-profile lookup map: cali_idx -> k_value
+    # This allows looking up the calibrated K value for each AMS slot
+    kprofile_map: dict[int, float] = {}
+    for kp in state.kprofiles or []:
+        if kp.slot_id is not None and kp.k_value:
+            try:
+                kprofile_map[kp.slot_id] = float(kp.k_value)
+            except (ValueError, TypeError):
+                pass
+
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         ams_exists = True
         for ams_data in raw_data["ams"]:
@@ -184,6 +194,13 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
                 tray_uuid = tray_data.get("tray_uuid", "")
                 if tray_uuid in ("", "00000000000000000000000000000000"):
                     tray_uuid = None
+
+                # Get K value: first try tray's k field, then lookup from K-profiles
+                k_value = tray_data.get("k")
+                cali_idx = tray_data.get("cali_idx")
+                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
+                    k_value = kprofile_map[cali_idx]
+
                 trays.append(
                     AMSTray(
                         id=tray_data.get("id", 0),
@@ -193,7 +210,8 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
                         tray_id_name=tray_data.get("tray_id_name"),
                         tray_info_idx=tray_data.get("tray_info_idx"),
                         remain=tray_data.get("remain", 0),
-                        k=tray_data.get("k"),
+                        k=k_value,
+                        cali_idx=cali_idx,
                         tag_uid=tag_uid,
                         tray_uuid=tray_uuid,
                         nozzle_temp_min=tray_data.get("nozzle_temp_min"),
@@ -239,13 +257,23 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         vt_tray_uuid = vt_data.get("tray_uuid", "")
         if vt_tray_uuid in ("", "00000000000000000000000000000000"):
             vt_tray_uuid = None
+
+        # Get K value: first try tray's k field, then lookup from K-profiles
+        vt_k_value = vt_data.get("k")
+        vt_cali_idx = vt_data.get("cali_idx")
+        if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
+            vt_k_value = kprofile_map[vt_cali_idx]
+
         vt_tray = AMSTray(
             id=254,  # Virtual tray ID
             tray_color=vt_data.get("tray_color"),
             tray_type=vt_data.get("tray_type"),
             tray_sub_brands=vt_data.get("tray_sub_brands"),
+            tray_id_name=vt_data.get("tray_id_name"),
+            tray_info_idx=vt_data.get("tray_info_idx"),
             remain=vt_data.get("remain", 0),
-            k=vt_data.get("k"),
+            k=vt_k_value,
+            cali_idx=vt_cali_idx,
             tag_uid=vt_tag_uid,
             tray_uuid=vt_tray_uuid,
             nozzle_temp_min=vt_data.get("nozzle_temp_min"),
@@ -977,7 +1005,7 @@ async def debug_simulate_print_complete(
         "timelapse_was_active": False,
     }
 
-    logger.info(f"[DEBUG] Simulating print complete for printer {printer_id}, archive {archive.id}")
+    logger.info(f"Simulating print complete for printer {printer_id}, archive {archive.id}")
 
     # Call the actual on_print_complete handler
     await on_print_complete(printer_id, data)

+ 432 - 67
backend/app/api/routes/settings.py

@@ -14,10 +14,11 @@ from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.filament import Filament
-from backend.app.models.maintenance import MaintenanceType
+from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
@@ -42,14 +43,13 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
 
 async def set_setting(db: AsyncSession, key: str, value: str) -> None:
     """Set a single setting value."""
-    result = await db.execute(select(Settings).where(Settings.key == key))
-    setting = result.scalar_one_or_none()
+    from sqlalchemy import func
+    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
 
-    if setting:
-        setting.value = value
-    else:
-        setting = Settings(key=key, value=value)
-        db.add(setting)
+    # Use upsert (INSERT ... ON CONFLICT UPDATE) for reliability
+    stmt = sqlite_insert(Settings).values(key=key, value=value)
+    stmt = stmt.on_conflict_do_update(index_elements=["key"], set_={"value": value, "updated_at": func.now()})
+    await db.execute(stmt)
 
 
 @router.get("/", response_model=AppSettings)
@@ -180,7 +180,10 @@ async def export_backup(
     include_external_links: bool = Query(True, description="Include external sidebar links"),
     include_printers: bool = Query(False, description="Include printers (without access codes)"),
     include_filaments: bool = Query(False, description="Include filament inventory"),
-    include_maintenance: bool = Query(False, description="Include maintenance types and records"),
+    include_maintenance: bool = Query(
+        False, description="Include maintenance types, per-printer settings, and history"
+    ),
+    include_print_queue: bool = Query(False, description="Include print queue items"),
     include_archives: bool = Query(False, description="Include print archive metadata"),
     include_projects: bool = Query(False, description="Include projects with BOM items"),
     include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
@@ -202,10 +205,20 @@ async def export_backup(
 
     # Notification providers
     if include_notifications:
+        # Build printer ID to serial lookup for cross-system backup
+        printer_id_to_serial: dict[int, str] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_id_to_serial[pr.id] = pr.serial_number
+
         result = await db.execute(select(NotificationProvider))
         providers = result.scalars().all()
         backup["notification_providers"] = []
         for p in providers:
+            # Use printer_serial for cross-system compatibility
+            provider_printer_id = getattr(p, "printer_id", None)
+            printer_serial = printer_id_to_serial.get(provider_printer_id) if provider_printer_id else None
+
             backup["notification_providers"].append(
                 {
                     "name": p.name,
@@ -221,12 +234,16 @@ async def export_backup(
                     "on_printer_error": p.on_printer_error,
                     "on_filament_low": p.on_filament_low,
                     "on_maintenance_due": p.on_maintenance_due,
+                    "on_ams_humidity_high": getattr(p, "on_ams_humidity_high", False),
+                    "on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False),
+                    "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
+                    "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
                     "quiet_hours_enabled": p.quiet_hours_enabled,
                     "quiet_hours_start": p.quiet_hours_start,
                     "quiet_hours_end": p.quiet_hours_end,
                     "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
                     "daily_digest_time": getattr(p, "daily_digest_time", None),
-                    "printer_id": getattr(p, "printer_id", None),
+                    "printer_serial": printer_serial,
                 }
             )
         backup["included"].append("notification_providers")
@@ -253,12 +270,19 @@ async def export_backup(
         result = await db.execute(select(SmartPlug))
         plugs = result.scalars().all()
         backup["smart_plugs"] = []
+
+        # Build printer ID to serial mapping
+        printer_id_to_serial: dict[int, str] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_id_to_serial[pr.id] = pr.serial_number
+
         for plug in plugs:
             backup["smart_plugs"].append(
                 {
                     "name": plug.name,
                     "ip_address": plug.ip_address,
-                    "printer_id": plug.printer_id,
+                    "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "enabled": plug.enabled,
                     "auto_on": plug.auto_on,
                     "auto_off": plug.auto_off,
@@ -316,6 +340,7 @@ async def export_backup(
                 "is_active": printer.is_active,
                 "auto_archive": printer.auto_archive,
                 "print_hours_offset": printer.print_hours_offset,
+                "runtime_seconds": printer.runtime_seconds,
             }
             if include_access_codes:
                 printer_data["access_code"] = printer.access_code
@@ -368,6 +393,99 @@ async def export_backup(
             )
         backup["included"].append("maintenance_types")
 
+        # Printer maintenance settings (per-printer custom intervals, enabled status, last performed)
+        result = await db.execute(select(PrinterMaintenance))
+        printer_maint = result.scalars().all()
+        backup["printer_maintenance"] = []
+
+        # Build lookups for printer serial and maintenance type name
+        printer_id_to_serial: dict[int, str] = {}
+        maint_type_id_to_name: dict[int, str] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_id_to_serial[pr.id] = pr.serial_number
+        for mt in types:
+            maint_type_id_to_name[mt.id] = mt.name
+
+        for pm in printer_maint:
+            backup["printer_maintenance"].append(
+                {
+                    "printer_serial": printer_id_to_serial.get(pm.printer_id),
+                    "maintenance_type_name": maint_type_id_to_name.get(pm.maintenance_type_id),
+                    "custom_interval_hours": pm.custom_interval_hours,
+                    "custom_interval_type": pm.custom_interval_type,
+                    "enabled": pm.enabled,
+                    "last_performed_at": pm.last_performed_at.isoformat() if pm.last_performed_at else None,
+                    "last_performed_hours": pm.last_performed_hours,
+                }
+            )
+        backup["included"].append("printer_maintenance")
+
+        # Maintenance history
+        result = await db.execute(select(MaintenanceHistory))
+        history = result.scalars().all()
+        backup["maintenance_history"] = []
+
+        # Build printer_maintenance ID to (printer_serial, maint_type_name) mapping
+        pm_id_to_info: dict[int, tuple[str | None, str | None]] = {}
+        for pm in printer_maint:
+            pm_id_to_info[pm.id] = (
+                printer_id_to_serial.get(pm.printer_id),
+                maint_type_id_to_name.get(pm.maintenance_type_id),
+            )
+
+        for mh in history:
+            info = pm_id_to_info.get(mh.printer_maintenance_id, (None, None))
+            backup["maintenance_history"].append(
+                {
+                    "printer_serial": info[0],
+                    "maintenance_type_name": info[1],
+                    "performed_at": mh.performed_at.isoformat() if mh.performed_at else None,
+                    "hours_at_maintenance": mh.hours_at_maintenance,
+                    "notes": mh.notes,
+                }
+            )
+        backup["included"].append("maintenance_history")
+
+    # Print queue
+    if include_print_queue:
+        result = await db.execute(select(PrintQueueItem))
+        queue_items = result.scalars().all()
+        backup["print_queue"] = []
+
+        # Build lookups
+        printer_id_to_serial: dict[int, str] = {}
+        archive_id_to_hash: dict[int, str | None] = {}
+        project_id_to_name: dict[int, str] = {}
+
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_id_to_serial[pr.id] = pr.serial_number
+        ar_result = await db.execute(select(PrintArchive))
+        for ar in ar_result.scalars().all():
+            archive_id_to_hash[ar.id] = ar.content_hash
+        proj_result = await db.execute(select(Project))
+        for proj in proj_result.scalars().all():
+            project_id_to_name[proj.id] = proj.name
+
+        for qi in queue_items:
+            backup["print_queue"].append(
+                {
+                    "printer_serial": printer_id_to_serial.get(qi.printer_id),
+                    "archive_hash": archive_id_to_hash.get(qi.archive_id),
+                    "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None,
+                    "position": qi.position,
+                    "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
+                    "require_previous_success": qi.require_previous_success,
+                    "auto_off_after": qi.auto_off_after,
+                    "status": qi.status,
+                    "started_at": qi.started_at.isoformat() if qi.started_at else None,
+                    "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
+                    "error_message": qi.error_message,
+                }
+            )
+        backup["included"].append("print_queue")
+
     # Collect files for ZIP (icons + archives)
     backup_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
 
@@ -394,10 +512,18 @@ async def export_backup(
             for proj in proj_result.scalars().all():
                 project_id_to_name[proj.id] = proj.name
 
+        # Build printer ID to serial mapping for archive export
+        printer_id_to_serial: dict[int, str] = {}
+        if include_printers:
+            printer_result = await db.execute(select(Printer))
+            for pr in printer_result.scalars().all():
+                printer_id_to_serial[pr.id] = pr.serial_number
+
         for a in archives:
             archive_data = {
                 "filename": a.filename,
                 "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
+                "printer_serial": printer_id_to_serial.get(a.printer_id) if a.printer_id else None,
                 "file_size": a.file_size,
                 "content_hash": a.content_hash,
                 "print_name": a.print_name,
@@ -485,6 +611,8 @@ async def export_backup(
                 "priority": p.priority,
                 "budget": p.budget,
                 "is_template": p.is_template,
+                "template_source_id": p.template_source_id,
+                "parent_id": p.parent_id,
                 "bom_items": [
                     {
                         "name": item.name,
@@ -671,10 +799,79 @@ async def import_backup(
                 str_value = str(value)
             await set_setting(db, key, str_value)
             restored["settings"] += 1
+        # Flush settings to ensure they're persisted before continuing
+        await db.flush()
+
+    # Restore printers FIRST (skip or overwrite duplicates by serial_number)
+    # Nearly everything in the app references printers, so they must be imported first
+    if "printers" in backup:
+        for printer_data in backup["printers"]:
+            result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    existing.name = printer_data["name"]
+                    existing.ip_address = printer_data["ip_address"]
+                    existing.model = printer_data.get("model")
+                    existing.location = printer_data.get("location")
+                    existing.nozzle_count = printer_data.get("nozzle_count", 1)
+                    existing.auto_archive = printer_data.get("auto_archive", True)
+                    existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
+                    existing.runtime_seconds = printer_data.get("runtime_seconds", 0)
+
+                    # If backup includes access_code, also update access_code and is_active
+                    backup_access_code = printer_data.get("access_code")
+                    if backup_access_code and backup_access_code != "CHANGE_ME":
+                        existing.access_code = backup_access_code
+                        is_active_val = printer_data.get("is_active", False)
+                        if isinstance(is_active_val, str):
+                            is_active_val = is_active_val.lower() == "true"
+                        existing.is_active = is_active_val
+
+                    restored["printers"] += 1
+                else:
+                    skipped["printers"] += 1
+                    skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
+            else:
+                # Use access code from backup if provided, otherwise require manual setup
+                access_code = printer_data.get("access_code")
+                has_access_code = access_code and access_code != "CHANGE_ME"
+                is_active_from_backup = printer_data.get("is_active", False)
+                # Handle bool or string "true"/"false"
+                if isinstance(is_active_from_backup, str):
+                    is_active_from_backup = is_active_from_backup.lower() == "true"
+
+                printer = Printer(
+                    name=printer_data["name"],
+                    serial_number=printer_data["serial_number"],
+                    ip_address=printer_data["ip_address"],
+                    access_code=access_code if has_access_code else "CHANGE_ME",
+                    model=printer_data.get("model"),
+                    location=printer_data.get("location"),
+                    nozzle_count=printer_data.get("nozzle_count", 1),
+                    is_active=is_active_from_backup if has_access_code else False,
+                    auto_archive=printer_data.get("auto_archive", True),
+                    print_hours_offset=printer_data.get("print_hours_offset", 0.0),
+                    runtime_seconds=printer_data.get("runtime_seconds", 0),
+                )
+                db.add(printer)
+                restored["printers"] += 1
+        # Flush printers so other sections can look them up
+        await db.flush()
 
     # Restore notification providers (skip or overwrite duplicates by name)
+    # Build printer serial to ID lookup (printers were restored first)
     if "notification_providers" in backup:
+        printer_serial_to_id: dict[str, int] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_serial_to_id[pr.serial_number] = pr.id
+
         for provider_data in backup["notification_providers"]:
+            # Look up printer_id from serial (supports both old printer_id and new printer_serial format)
+            printer_serial = provider_data.get("printer_serial")
+            printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else provider_data.get("printer_id")
+
             result = await db.execute(
                 select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
             )
@@ -694,12 +891,16 @@ async def import_backup(
                     existing.on_printer_error = provider_data.get("on_printer_error", False)
                     existing.on_filament_low = provider_data.get("on_filament_low", False)
                     existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
+                    existing.on_ams_humidity_high = provider_data.get("on_ams_humidity_high", False)
+                    existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False)
+                    existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
+                    existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
                     existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
                     existing.quiet_hours_start = provider_data.get("quiet_hours_start")
                     existing.quiet_hours_end = provider_data.get("quiet_hours_end")
                     existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
                     existing.daily_digest_time = provider_data.get("daily_digest_time")
-                    existing.printer_id = provider_data.get("printer_id")
+                    existing.printer_id = printer_id
                     restored["notification_providers"] += 1
                 else:
                     skipped["notification_providers"] += 1
@@ -719,12 +920,16 @@ async def import_backup(
                     on_printer_error=provider_data.get("on_printer_error", False),
                     on_filament_low=provider_data.get("on_filament_low", False),
                     on_maintenance_due=provider_data.get("on_maintenance_due", False),
+                    on_ams_humidity_high=provider_data.get("on_ams_humidity_high", False),
+                    on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False),
+                    on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
+                    on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
                     quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
                     quiet_hours_start=provider_data.get("quiet_hours_start"),
                     quiet_hours_end=provider_data.get("quiet_hours_end"),
                     daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
                     daily_digest_time=provider_data.get("daily_digest_time"),
-                    printer_id=provider_data.get("printer_id"),
+                    printer_id=printer_id,
                 )
                 db.add(provider)
                 restored["notification_providers"] += 1
@@ -754,14 +959,25 @@ async def import_backup(
             restored["notification_templates"] += 1
 
     # Restore smart plugs (skip or overwrite duplicates by IP)
+    # Note: Smart plugs reference printers, so printers should be restored first
     if "smart_plugs" in backup:
+        # Build printer serial to ID lookup
+        printer_serial_to_id: dict[str, int] = {}
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_serial_to_id[pr.serial_number] = pr.id
+
         for plug_data in backup["smart_plugs"]:
+            # Look up printer_id from serial (supports both old printer_id and new printer_serial format)
+            printer_serial = plug_data.get("printer_serial")
+            printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id")
+
             result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
             existing = result.scalar_one_or_none()
             if existing:
                 if overwrite:
                     existing.name = plug_data["name"]
-                    existing.printer_id = plug_data.get("printer_id")
+                    existing.printer_id = printer_id
                     existing.enabled = plug_data.get("enabled", True)
                     existing.auto_on = plug_data.get("auto_on", True)
                     existing.auto_off = plug_data.get("auto_off", True)
@@ -785,7 +1001,7 @@ async def import_backup(
                 plug = SmartPlug(
                     name=plug_data["name"],
                     ip_address=plug_data["ip_address"],
-                    printer_id=plug_data.get("printer_id"),
+                    printer_id=printer_id,
                     enabled=plug_data.get("enabled", True),
                     auto_on=plug_data.get("auto_on", True),
                     auto_off=plug_data.get("auto_off", True),
@@ -837,58 +1053,6 @@ async def import_backup(
                 db.add(link)
                 restored["external_links"] += 1
 
-    # Restore printers (skip or overwrite duplicates by serial_number)
-    if "printers" in backup:
-        for printer_data in backup["printers"]:
-            result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    existing.name = printer_data["name"]
-                    existing.ip_address = printer_data["ip_address"]
-                    existing.model = printer_data.get("model")
-                    existing.location = printer_data.get("location")
-                    existing.nozzle_count = printer_data.get("nozzle_count", 1)
-                    existing.auto_archive = printer_data.get("auto_archive", True)
-                    existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
-
-                    # If backup includes access_code, also update access_code and is_active
-                    backup_access_code = printer_data.get("access_code")
-                    if backup_access_code and backup_access_code != "CHANGE_ME":
-                        existing.access_code = backup_access_code
-                        is_active_val = printer_data.get("is_active", False)
-                        if isinstance(is_active_val, str):
-                            is_active_val = is_active_val.lower() == "true"
-                        existing.is_active = is_active_val
-
-                    restored["printers"] += 1
-                else:
-                    skipped["printers"] += 1
-                    skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
-            else:
-                # Use access code from backup if provided, otherwise require manual setup
-                access_code = printer_data.get("access_code")
-                has_access_code = access_code and access_code != "CHANGE_ME"
-                is_active_from_backup = printer_data.get("is_active", False)
-                # Handle bool or string "true"/"false"
-                if isinstance(is_active_from_backup, str):
-                    is_active_from_backup = is_active_from_backup.lower() == "true"
-
-                printer = Printer(
-                    name=printer_data["name"],
-                    serial_number=printer_data["serial_number"],
-                    ip_address=printer_data["ip_address"],
-                    access_code=access_code if has_access_code else "CHANGE_ME",
-                    model=printer_data.get("model"),
-                    location=printer_data.get("location"),
-                    nozzle_count=printer_data.get("nozzle_count", 1),
-                    is_active=is_active_from_backup if has_access_code else False,
-                    auto_archive=printer_data.get("auto_archive", True),
-                    print_hours_offset=printer_data.get("print_hours_offset", 0.0),
-                )
-                db.add(printer)
-                restored["printers"] += 1
-
     # Restore filaments (skip or overwrite duplicates by name+type+brand)
     if "filaments" in backup:
         for filament_data in backup["filaments"]:
@@ -965,8 +1129,138 @@ async def import_backup(
                 db.add(mt)
                 restored["maintenance_types"] += 1
 
+    # Restore printer maintenance settings (per-printer)
+    if "printer_maintenance" in backup:
+        # Build lookups
+        printer_serial_to_id: dict[str, int] = {}
+        maint_type_name_to_id: dict[str, int] = {}
+
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_serial_to_id[pr.serial_number] = pr.id
+
+        mt_result = await db.execute(select(MaintenanceType))
+        for mt in mt_result.scalars().all():
+            maint_type_name_to_id[mt.name] = mt.id
+
+        restored["printer_maintenance"] = 0
+        skipped["printer_maintenance"] = 0
+        skipped_details["printer_maintenance"] = []
+
+        for pm_data in backup["printer_maintenance"]:
+            printer_serial = pm_data.get("printer_serial")
+            maint_type_name = pm_data.get("maintenance_type_name")
+
+            if not printer_serial or not maint_type_name:
+                continue
+
+            printer_id = printer_serial_to_id.get(printer_serial)
+            maint_type_id = maint_type_name_to_id.get(maint_type_name)
+
+            if not printer_id or not maint_type_id:
+                skipped["printer_maintenance"] += 1
+                skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
+                continue
+
+            # Check if exists
+            result = await db.execute(
+                select(PrinterMaintenance).where(
+                    PrinterMaintenance.printer_id == printer_id,
+                    PrinterMaintenance.maintenance_type_id == maint_type_id,
+                )
+            )
+            existing = result.scalar_one_or_none()
+
+            if existing:
+                if overwrite:
+                    existing.custom_interval_hours = pm_data.get("custom_interval_hours")
+                    existing.custom_interval_type = pm_data.get("custom_interval_type")
+                    existing.enabled = pm_data.get("enabled", True)
+                    existing.last_performed_hours = pm_data.get("last_performed_hours", 0.0)
+                    if pm_data.get("last_performed_at"):
+                        existing.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
+                    restored["printer_maintenance"] += 1
+                else:
+                    skipped["printer_maintenance"] += 1
+                    skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
+            else:
+                pm = PrinterMaintenance(
+                    printer_id=printer_id,
+                    maintenance_type_id=maint_type_id,
+                    custom_interval_hours=pm_data.get("custom_interval_hours"),
+                    custom_interval_type=pm_data.get("custom_interval_type"),
+                    enabled=pm_data.get("enabled", True),
+                    last_performed_hours=pm_data.get("last_performed_hours", 0.0),
+                )
+                if pm_data.get("last_performed_at"):
+                    pm.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
+                db.add(pm)
+                restored["printer_maintenance"] += 1
+
+    # Restore maintenance history
+    if "maintenance_history" in backup:
+        # Build lookups
+        printer_serial_to_id: dict[str, int] = {}
+        maint_type_name_to_id: dict[str, int] = {}
+
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_serial_to_id[pr.serial_number] = pr.id
+
+        mt_result = await db.execute(select(MaintenanceType))
+        for mt in mt_result.scalars().all():
+            maint_type_name_to_id[mt.name] = mt.id
+
+        restored["maintenance_history"] = 0
+        skipped["maintenance_history"] = 0
+        skipped_details["maintenance_history"] = []
+
+        for mh_data in backup["maintenance_history"]:
+            printer_serial = mh_data.get("printer_serial")
+            maint_type_name = mh_data.get("maintenance_type_name")
+
+            if not printer_serial or not maint_type_name:
+                continue
+
+            printer_id = printer_serial_to_id.get(printer_serial)
+            maint_type_id = maint_type_name_to_id.get(maint_type_name)
+
+            if not printer_id or not maint_type_id:
+                skipped["maintenance_history"] += 1
+                continue
+
+            # Find the PrinterMaintenance record
+            result = await db.execute(
+                select(PrinterMaintenance).where(
+                    PrinterMaintenance.printer_id == printer_id,
+                    PrinterMaintenance.maintenance_type_id == maint_type_id,
+                )
+            )
+            pm = result.scalar_one_or_none()
+
+            if not pm:
+                skipped["maintenance_history"] += 1
+                continue
+
+            # Create history entry (no duplicate check - history is append-only)
+            mh = MaintenanceHistory(
+                printer_maintenance_id=pm.id,
+                hours_at_maintenance=mh_data.get("hours_at_maintenance", 0.0),
+                notes=mh_data.get("notes"),
+            )
+            if mh_data.get("performed_at"):
+                mh.performed_at = datetime.fromisoformat(mh_data["performed_at"])
+            db.add(mh)
+            restored["maintenance_history"] += 1
+
     # Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
     if "archives" in backup:
+        # Build printer serial to ID mapping
+        printer_serial_to_id: dict[str, int] = {}
+        printer_result = await db.execute(select(Printer))
+        for pr in printer_result.scalars().all():
+            printer_serial_to_id[pr.serial_number] = pr.id
+
         for archive_data in backup["archives"]:
             # Skip if no content_hash or already exists
             content_hash = archive_data.get("content_hash")
@@ -981,11 +1275,16 @@ async def import_backup(
             # Only restore if file exists (from ZIP extraction)
             file_path = archive_data.get("file_path")
             if file_path and (base_dir / file_path).exists():
+                # Look up printer_id from serial
+                printer_serial = archive_data.get("printer_serial")
+                printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
+
                 archive = PrintArchive(
                     filename=archive_data["filename"],
                     file_path=file_path,
                     file_size=archive_data.get("file_size", 0),
                     content_hash=content_hash,
+                    printer_id=printer_id,
                     thumbnail_path=archive_data.get("thumbnail_path"),
                     timelapse_path=archive_data.get("timelapse_path"),
                     source_3mf_path=archive_data.get("source_3mf_path"),
@@ -1032,6 +1331,8 @@ async def import_backup(
                     existing.priority = project_data.get("priority", "normal")
                     existing.budget = project_data.get("budget")
                     existing.is_template = project_data.get("is_template", False)
+                    existing.template_source_id = project_data.get("template_source_id")
+                    existing.parent_id = project_data.get("parent_id")
                     existing.attachments = project_data.get("attachments")
                     if project_data.get("due_date"):
                         existing.due_date = datetime.fromisoformat(project_data["due_date"])
@@ -1069,6 +1370,8 @@ async def import_backup(
                     priority=project_data.get("priority", "normal"),
                     budget=project_data.get("budget"),
                     is_template=project_data.get("is_template", False),
+                    template_source_id=project_data.get("template_source_id"),
+                    parent_id=project_data.get("parent_id"),
                     attachments=project_data.get("attachments"),
                 )
                 if project_data.get("due_date"):
@@ -1113,6 +1416,68 @@ async def import_backup(
                     if archive:
                         archive.project_id = project_name_to_id[project_name]
 
+    # Restore print queue (must be after archives and projects)
+    if "print_queue" in backup:
+        # Build lookups
+        printer_serial_to_id: dict[str, int] = {}
+        archive_hash_to_id: dict[str, int] = {}
+        project_name_to_id: dict[str, int] = {}
+
+        pr_result = await db.execute(select(Printer))
+        for pr in pr_result.scalars().all():
+            printer_serial_to_id[pr.serial_number] = pr.id
+
+        ar_result = await db.execute(select(PrintArchive))
+        for ar in ar_result.scalars().all():
+            if ar.content_hash:
+                archive_hash_to_id[ar.content_hash] = ar.id
+
+        proj_result = await db.execute(select(Project))
+        for proj in proj_result.scalars().all():
+            project_name_to_id[proj.name] = proj.id
+
+        restored["print_queue"] = 0
+        skipped["print_queue"] = 0
+        skipped_details["print_queue"] = []
+
+        for qi_data in backup["print_queue"]:
+            printer_serial = qi_data.get("printer_serial")
+            archive_hash = qi_data.get("archive_hash")
+
+            if not printer_serial or not archive_hash:
+                skipped["print_queue"] += 1
+                continue
+
+            printer_id = printer_serial_to_id.get(printer_serial)
+            archive_id = archive_hash_to_id.get(archive_hash)
+
+            if not printer_id or not archive_id:
+                skipped["print_queue"] += 1
+                skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8] if archive_hash else 'N/A'}")
+                continue
+
+            project_name = qi_data.get("project_name")
+            project_id = project_name_to_id.get(project_name) if project_name else None
+
+            qi = PrintQueueItem(
+                printer_id=printer_id,
+                archive_id=archive_id,
+                project_id=project_id,
+                position=qi_data.get("position", 0),
+                require_previous_success=qi_data.get("require_previous_success", False),
+                auto_off_after=qi_data.get("auto_off_after", False),
+                status=qi_data.get("status", "pending"),
+                error_message=qi_data.get("error_message"),
+            )
+            if qi_data.get("scheduled_time"):
+                qi.scheduled_time = datetime.fromisoformat(qi_data["scheduled_time"])
+            if qi_data.get("started_at"):
+                qi.started_at = datetime.fromisoformat(qi_data["started_at"])
+            if qi_data.get("completed_at"):
+                qi.completed_at = datetime.fromisoformat(qi_data["completed_at"])
+            db.add(qi)
+            restored["print_queue"] += 1
+
     # Restore pending uploads (skip duplicates by filename)
     if "pending_uploads" in backup:
         # Ensure the pending uploads directory exists

+ 18 - 8
backend/app/core/config.py

@@ -1,4 +1,5 @@
 import logging
+import os
 from pathlib import Path
 
 from pydantic_settings import BaseSettings
@@ -7,14 +8,23 @@ from pydantic_settings import BaseSettings
 APP_VERSION = "0.1.6b3"
 GITHUB_REPO = "maziggy/bambuddy"
 
-# Base directory for path calculations
-_base_dir = Path(__file__).resolve().parent.parent.parent.parent
+# App directory - where the application is installed (for static files)
+_app_dir = Path(__file__).resolve().parent.parent.parent.parent
+
+# Data directory - for persistent data (database, archives)
+# Use DATA_DIR env var if set (Docker), otherwise use project root (local dev)
+_data_dir_env = os.environ.get("DATA_DIR")
+_data_dir = Path(_data_dir_env) if _data_dir_env else _app_dir
+
+# Log directory - use LOG_DIR env var if set, otherwise use app_dir/logs
+_log_dir_env = os.environ.get("LOG_DIR")
+_log_dir = Path(_log_dir_env) if _log_dir_env else _app_dir / "logs"
 
 
 def _migrate_database() -> Path:
     """Migrate database from old name to new name if needed."""
-    old_db = _base_dir / "bambutrack.db"
-    new_db = _base_dir / "bambuddy.db"
+    old_db = _data_dir / "bambutrack.db"
+    new_db = _data_dir / "bambuddy.db"
 
     # If old database exists and new one doesn't, rename it
     if old_db.exists() and not new_db.exists():
@@ -40,10 +50,10 @@ class Settings(BaseSettings):
     debug: bool = False  # Default to production mode
 
     # Paths
-    base_dir: Path = _base_dir
-    archive_dir: Path = base_dir / "archive"
-    static_dir: Path = base_dir / "static"
-    log_dir: Path = base_dir / "logs"
+    base_dir: Path = _data_dir  # For backwards compatibility
+    archive_dir: Path = _data_dir / "archive"
+    static_dir: Path = _app_dir / "static"  # Static files are part of app, not data
+    log_dir: Path = _log_dir
     database_url: str = f"sqlite+aiosqlite:///{_db_path}"
 
     # Logging

+ 12 - 3
backend/app/main.py

@@ -1466,6 +1466,8 @@ async def track_printer_runtime():
                 now = datetime.now()
                 updated_count = 0
 
+                needs_commit = False
+
                 for printer in printers:
                     # Get current state from printer manager
                     state = printer_manager.get_status(printer.id)
@@ -1481,15 +1483,22 @@ async def track_printer_runtime():
                             if elapsed > 0 and elapsed < RUNTIME_TRACKING_INTERVAL * 2:
                                 printer.runtime_seconds += int(elapsed)
                                 updated_count += 1
+                                needs_commit = True
+                        else:
+                            # First time seeing printer active - need to commit to save timestamp
+                            needs_commit = True
 
                         printer.last_runtime_update = now
                     else:
                         # Printer is idle/offline - clear last_runtime_update
-                        printer.last_runtime_update = None
+                        if printer.last_runtime_update is not None:
+                            printer.last_runtime_update = None
+                            needs_commit = True
 
-                if updated_count > 0:
+                if needs_commit:
                     await db.commit()
-                    logger.debug(f"Updated runtime for {updated_count} printer(s)")
+                    if updated_count > 0:
+                        logger.debug(f"Updated runtime for {updated_count} printer(s)")
 
         except asyncio.CancelledError:
             logger.info("Runtime tracking cancelled")

+ 4 - 1
backend/app/schemas/printer.py

@@ -1,4 +1,5 @@
 from datetime import datetime
+
 from pydantic import BaseModel, Field
 
 
@@ -54,7 +55,8 @@ class AMSTray(BaseModel):
     tray_id_name: str | None = None  # Bambu filament ID like "A00-Y2" (can decode to color)
     tray_info_idx: str | None = None  # Filament preset ID like "GFA00"
     remain: int = 0
-    k: float | None = None  # Pressure advance value
+    k: float | None = None  # Pressure advance value (from tray or K-profile lookup)
+    cali_idx: int | None = None  # Calibration index for K-profile lookup
     tag_uid: str | None = None  # RFID tag UID (any tag)
     tray_uuid: str | None = None  # Bambu Lab spool UUID (32-char hex)
     nozzle_temp_min: int | None = None  # Min nozzle temperature
@@ -76,6 +78,7 @@ class NozzleInfoResponse(BaseModel):
 
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
+
     # Core AI detectors
     spaghetti_detector: bool = False
     print_halt: bool = False

+ 18 - 0
backend/app/schemas/settings.py

@@ -56,6 +56,18 @@ class AppSettings(BaseModel):
     virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
     virtual_printer_mode: str = Field(default="immediate", description="Archive mode: 'immediate' or 'queue'")
 
+    # Dark mode theme settings
+    dark_style: str = Field(default="classic", description="Dark mode style: classic, glow, vibrant")
+    dark_background: str = Field(
+        default="neutral", description="Dark mode background: neutral, warm, cool, oled, slate, forest"
+    )
+    dark_accent: str = Field(default="green", description="Dark mode accent: green, teal, blue, orange, purple, red")
+
+    # Light mode theme settings
+    light_style: str = Field(default="classic", description="Light mode style: classic, glow, vibrant")
+    light_background: str = Field(default="neutral", description="Light mode background: neutral, warm, cool")
+    light_accent: str = Field(default="green", description="Light mode accent: green, teal, blue, orange, purple, red")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -84,3 +96,9 @@ class AppSettingsUpdate(BaseModel):
     virtual_printer_enabled: bool | None = None
     virtual_printer_access_code: str | None = None
     virtual_printer_mode: str | None = None
+    dark_style: str | None = None
+    dark_background: str | None = None
+    dark_accent: str | None = None
+    light_style: str | None = None
+    light_background: str | None = None
+    light_accent: str | None = None

+ 39 - 31
backend/app/services/bambu_mqtt.py

@@ -733,9 +733,9 @@ class BambuMQTTClient:
                     parsed_tray_now = raw_tray_now if raw_tray_now is not None else 255
 
                 # H2D dual-nozzle printers report only slot number (0-3), not global tray ID
-                # Use pending_tray_target from our load command tracking for disambiguation
+                # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
                 if parsed_tray_now >= 0 and parsed_tray_now <= 3:
-                    # Check if we have a pending target that matches this slot
+                    # First, check if we have a pending target that matches this slot
                     pending_target = self.state.pending_tray_target
                     if pending_target is not None:
                         pending_slot = pending_target % 4
@@ -758,28 +758,45 @@ class BambuMQTTClient:
                             # Clear pending target since it's stale
                             self.state.pending_tray_target = None
                     else:
-                        # No pending target - check if we already have a resolved global ID
-                        # that matches this slot (from a previous successful disambiguation)
-                        current_tray = self.state.tray_now
-                        if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
-                            # Current tray_now is already a valid global ID that matches this slot
-                            # Keep it (don't overwrite with raw slot number)
-                            logger.debug(
-                                f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
-                                f"(matches incoming slot {parsed_tray_now})"
+                        # No pending target - use active_extruder + ams_extruder_map to disambiguate
+                        # Find which AMS is connected to the active extruder
+                        active_ext = self.state.active_extruder  # 0=right, 1=left
+                        ams_map = self.state.ams_extruder_map  # {ams_id: extruder_id}
+
+                        # Find the AMS connected to the active extruder
+                        active_ams_id = None
+                        for ams_id_str, ext_id in ams_map.items():
+                            if ext_id == active_ext:
+                                try:
+                                    active_ams_id = int(ams_id_str)
+                                except ValueError:
+                                    pass
+                                break
+
+                        if active_ams_id is not None:
+                            # Calculate global tray ID using the active AMS
+                            global_tray_id = active_ams_id * 4 + parsed_tray_now
+                            logger.info(
+                                f"[{self.serial_number}] H2D tray_now disambiguation: "
+                                f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}"
                             )
+                            self.state.tray_now = global_tray_id
                         else:
-                            # No pending target and no valid existing global ID
-                            # For H2D with multiple AMS units, we can't reliably determine which AMS
-                            # the slot belongs to without a pending_tray_target from our load command.
-                            # Use slot number as-is - this may be incorrect for multi-AMS setups,
-                            # but it's better than guessing wrong based on unreliable heuristics.
-                            # The user can load filament via our API to get correct tracking.
-                            logger.warning(
-                                f"[{self.serial_number}] H2D tray_now: no pending target, "
-                                f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)"
-                            )
-                            self.state.tray_now = parsed_tray_now
+                            # No AMS found for active extruder - check if we already have a resolved global ID
+                            current_tray = self.state.tray_now
+                            if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
+                                # Current tray_now is already a valid global ID that matches this slot
+                                logger.debug(
+                                    f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
+                                    f"(matches incoming slot {parsed_tray_now})"
+                                )
+                            else:
+                                # Fallback: use slot as-is
+                                logger.warning(
+                                    f"[{self.serial_number}] H2D tray_now: no ams_extruder_map for active_extruder {active_ext}, "
+                                    f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)"
+                                )
+                                self.state.tray_now = parsed_tray_now
                 else:
                     # tray_now > 3 means it's already a global ID, or 255 means unloaded
                     # Note: Do NOT clear pending_tray_target on tray_now=255 here.
@@ -806,15 +823,6 @@ class BambuMQTTClient:
 
         # Extract ams_extruder_map from each AMS unit's info field
         # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
-        # Log AMS unit fields once to discover available fields
-        if not hasattr(self, "_ams_fields_logged") and ams_list:
-            first_unit = ams_list[0]
-            logger.info(f"[{self.serial_number}] AMS unit fields: {sorted(first_unit.keys())}")
-            for ams_unit in ams_list:
-                ams_id = ams_unit.get("id")
-                unit_info = {k: v for k, v in ams_unit.items() if k != "tray"}
-                logger.info(f"[{self.serial_number}] AMS {ams_id} data: {unit_info}")
-            self._ams_fields_logged = True
 
         ams_extruder_map = {}
         for ams_unit in ams_list:

+ 39 - 1
backend/app/services/printer_manager.py

@@ -338,6 +338,15 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
     vt_tray = None
     raw_data = state.raw_data or {}
 
+    # Build K-profile lookup map: cali_idx -> k_value
+    kprofile_map: dict[int, float] = {}
+    for kp in state.kprofiles or []:
+        if kp.slot_id is not None and kp.k_value:
+            try:
+                kprofile_map[kp.slot_id] = float(kp.k_value)
+            except (ValueError, TypeError):
+                pass
+
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         for ams_data in raw_data["ams"]:
             trays = []
@@ -348,16 +357,28 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
                 tray_uuid = tray.get("tray_uuid")
                 if tray_uuid in ("", "00000000000000000000000000000000"):
                     tray_uuid = None
+
+                # Get K value: first try tray's k field, then lookup from K-profiles
+                k_value = tray.get("k")
+                cali_idx = tray.get("cali_idx")
+                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
+                    k_value = kprofile_map[cali_idx]
+
                 trays.append(
                     {
                         "id": tray.get("id", 0),
                         "tray_color": tray.get("tray_color"),
                         "tray_type": tray.get("tray_type"),
                         "tray_sub_brands": tray.get("tray_sub_brands"),
+                        "tray_id_name": tray.get("tray_id_name"),
+                        "tray_info_idx": tray.get("tray_info_idx"),
                         "remain": tray.get("remain", 0),
-                        "k": tray.get("k"),
+                        "k": k_value,
+                        "cali_idx": cali_idx,
                         "tag_uid": tag_uid,
                         "tray_uuid": tray_uuid,
+                        "nozzle_temp_min": tray.get("nozzle_temp_min"),
+                        "nozzle_temp_max": tray.get("nozzle_temp_max"),
                     }
                 )
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
@@ -396,13 +417,30 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         vt_tag_uid = vt_data.get("tag_uid")
         if vt_tag_uid in ("", "0000000000000000"):
             vt_tag_uid = None
+        vt_tray_uuid = vt_data.get("tray_uuid")
+        if vt_tray_uuid in ("", "00000000000000000000000000000000"):
+            vt_tray_uuid = None
+
+        # Get K value for vt_tray
+        vt_k_value = vt_data.get("k")
+        vt_cali_idx = vt_data.get("cali_idx")
+        if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
+            vt_k_value = kprofile_map[vt_cali_idx]
+
         vt_tray = {
             "id": 254,
             "tray_color": vt_data.get("tray_color"),
             "tray_type": vt_data.get("tray_type"),
             "tray_sub_brands": vt_data.get("tray_sub_brands"),
+            "tray_id_name": vt_data.get("tray_id_name"),
+            "tray_info_idx": vt_data.get("tray_info_idx"),
             "remain": vt_data.get("remain", 0),
+            "k": vt_k_value,
+            "cali_idx": vt_cali_idx,
             "tag_uid": vt_tag_uid,
+            "tray_uuid": vt_tray_uuid,
+            "nozzle_temp_min": vt_data.get("nozzle_temp_min"),
+            "nozzle_temp_max": vt_data.get("nozzle_temp_max"),
         }
 
     # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)

+ 36 - 35
backend/tests/integration/test_settings_api.py

@@ -53,10 +53,7 @@ class TestSettingsAPI:
 
         # Update to opposite value
         new_value = not original
-        response = await async_client.put(
-            "/api/v1/settings/",
-            json={"auto_archive": new_value}
-        )
+        response = await async_client.put("/api/v1/settings/", json={"auto_archive": new_value})
 
         assert response.status_code == 200
         assert response.json()["auto_archive"] == new_value
@@ -65,10 +62,7 @@ class TestSettingsAPI:
     @pytest.mark.integration
     async def test_update_currency(self, async_client: AsyncClient):
         """Verify currency can be updated."""
-        response = await async_client.put(
-            "/api/v1/settings/",
-            json={"currency": "EUR"}
-        )
+        response = await async_client.put("/api/v1/settings/", json={"currency": "EUR"})
 
         assert response.status_code == 200
         assert response.json()["currency"] == "EUR"
@@ -77,10 +71,7 @@ class TestSettingsAPI:
     @pytest.mark.integration
     async def test_update_date_format(self, async_client: AsyncClient):
         """Verify date format can be updated."""
-        response = await async_client.put(
-            "/api/v1/settings/",
-            json={"date_format": "eu"}
-        )
+        response = await async_client.put("/api/v1/settings/", json={"date_format": "eu"})
 
         assert response.status_code == 200
         assert response.json()["date_format"] == "eu"
@@ -89,10 +80,7 @@ class TestSettingsAPI:
     @pytest.mark.integration
     async def test_update_time_format(self, async_client: AsyncClient):
         """Verify time format can be updated."""
-        response = await async_client.put(
-            "/api/v1/settings/",
-            json={"time_format": "24h"}
-        )
+        response = await async_client.put("/api/v1/settings/", json={"time_format": "24h"})
 
         assert response.status_code == 200
         assert response.json()["time_format"] == "24h"
@@ -101,10 +89,7 @@ class TestSettingsAPI:
     @pytest.mark.integration
     async def test_update_filament_cost(self, async_client: AsyncClient):
         """Verify default filament cost can be updated."""
-        response = await async_client.put(
-            "/api/v1/settings/",
-            json={"default_filament_cost": 30.0}
-        )
+        response = await async_client.put("/api/v1/settings/", json={"default_filament_cost": 30.0})
 
         assert response.status_code == 200
         assert response.json()["default_filament_cost"] == 30.0
@@ -113,10 +98,7 @@ class TestSettingsAPI:
     @pytest.mark.integration
     async def test_update_energy_cost(self, async_client: AsyncClient):
         """Verify energy cost can be updated."""
-        response = await async_client.put(
-            "/api/v1/settings/",
-            json={"energy_cost_per_kwh": 0.20}
-        )
+        response = await async_client.put("/api/v1/settings/", json={"energy_cost_per_kwh": 0.20})
 
         assert response.status_code == 200
         assert response.json()["energy_cost_per_kwh"] == 0.20
@@ -132,7 +114,7 @@ class TestSettingsAPI:
                 "date_format": "iso",
                 "time_format": "12h",
                 "save_thumbnails": False,
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -152,7 +134,7 @@ class TestSettingsAPI:
                 "spoolman_enabled": True,
                 "spoolman_url": "http://localhost:7912",
                 "spoolman_sync_mode": "manual",
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -172,7 +154,7 @@ class TestSettingsAPI:
                 "ams_humidity_fair": 55,
                 "ams_temp_good": 25.0,
                 "ams_temp_fair": 32.0,
-            }
+            },
         )
 
         assert response.status_code == 200
@@ -186,10 +168,7 @@ class TestSettingsAPI:
     @pytest.mark.integration
     async def test_update_notification_language(self, async_client: AsyncClient):
         """Verify notification language can be updated."""
-        response = await async_client.put(
-            "/api/v1/settings/",
-            json={"notification_language": "de"}
-        )
+        response = await async_client.put("/api/v1/settings/", json={"notification_language": "de"})
 
         assert response.status_code == 200
         assert response.json()["notification_language"] == "de"
@@ -198,15 +177,37 @@ class TestSettingsAPI:
     # Settings persistence tests
     # ========================================================================
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_theme_settings(self, async_client: AsyncClient):
+        """Verify theme settings can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={
+                "dark_style": "glow",
+                "dark_background": "forest",
+                "dark_accent": "teal",
+                "light_style": "vibrant",
+                "light_background": "warm",
+                "light_accent": "blue",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["dark_style"] == "glow"
+        assert result["dark_background"] == "forest"
+        assert result["dark_accent"] == "teal"
+        assert result["light_style"] == "vibrant"
+        assert result["light_background"] == "warm"
+        assert result["light_accent"] == "blue"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_settings_persist_after_update(self, async_client: AsyncClient):
         """CRITICAL: Verify settings changes persist across requests."""
         # Update settings
-        await async_client.put(
-            "/api/v1/settings/",
-            json={"currency": "JPY", "check_updates": False}
-        )
+        await async_client.put("/api/v1/settings/", json={"currency": "JPY", "check_updates": False})
 
         # Verify persistence in new request
         response = await async_client.get("/api/v1/settings/")

+ 1299 - 0
frontend/mockups/ams-redesign.html

@@ -0,0 +1,1299 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>AMS Section Redesign Mockup</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+  <style>
+    :root {
+      /* Match actual Bambuddy dark theme */
+      --bg-page: #121218;
+      --bg-card: #1a1a22;
+      --bg-section: #22222a;
+      --bg-input: #2a2a32;
+      --border-color: #333340;
+      --text-primary: #ffffff;
+      --text-secondary: #9ca3af;
+      --text-muted: #6b7280;
+      --bambu-green: #00ae42;
+      --bambu-green-bg: rgba(0, 174, 66, 0.2);
+      --humidity-good: #00ae42;
+      --humidity-fair: #f59e0b;
+      --humidity-bad: #ef4444;
+    }
+
+    * {
+      margin: 0;
+      padding: 0;
+      box-sizing: border-box;
+    }
+
+    body {
+      font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+      background: var(--bg-page);
+      color: var(--text-primary);
+      min-height: 100vh;
+      padding: 24px;
+    }
+
+    .page-header {
+      margin-bottom: 24px;
+    }
+
+    .page-title {
+      font-size: 18px;
+      font-weight: 600;
+      color: var(--text-primary);
+      margin-bottom: 4px;
+    }
+
+    .page-subtitle {
+      font-size: 13px;
+      color: var(--text-muted);
+    }
+
+    /* Printer Card - matches actual app */
+    .printer-card {
+      background: var(--bg-card);
+      border: 1px solid var(--border-color);
+      border-radius: 12px;
+      padding: 16px;
+      width: 340px;
+    }
+
+    /* Card Header */
+    .card-header {
+      display: flex;
+      align-items: flex-start;
+      gap: 12px;
+      margin-bottom: 8px;
+    }
+
+    .printer-image {
+      width: 56px;
+      height: 56px;
+      border-radius: 8px;
+      background: var(--bg-section);
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      overflow: hidden;
+    }
+
+    .printer-image svg {
+      width: 40px;
+      height: 40px;
+      color: var(--text-muted);
+    }
+
+    .printer-details {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .printer-name-row {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .printer-name {
+      font-size: 18px;
+      font-weight: 600;
+      color: var(--text-primary);
+    }
+
+    .menu-btn {
+      width: 24px;
+      height: 24px;
+      border: none;
+      background: transparent;
+      color: var(--text-secondary);
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 4px;
+    }
+
+    .menu-btn:hover {
+      background: var(--bg-section);
+    }
+
+    .printer-model {
+      font-size: 14px;
+      color: var(--text-secondary);
+      margin-top: 2px;
+    }
+
+    /* Badges row */
+    .badges-row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      margin-bottom: 16px;
+    }
+
+    .badge {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      padding: 4px 8px;
+      border-radius: 9999px;
+      font-size: 12px;
+      font-weight: 500;
+    }
+
+    .badge-green {
+      background: var(--bambu-green-bg);
+      color: var(--bambu-green);
+    }
+
+    .badge svg {
+      width: 12px;
+      height: 12px;
+    }
+
+    /* Status Section */
+    .status-section {
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 12px;
+      margin-bottom: 12px;
+    }
+
+    .status-row {
+      display: flex;
+      gap: 12px;
+    }
+
+    .cover-placeholder {
+      width: 72px;
+      height: 72px;
+      border-radius: 8px;
+      background: var(--bg-input);
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .cover-placeholder svg {
+      width: 32px;
+      height: 32px;
+      color: var(--text-muted);
+    }
+
+    .status-info {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .status-label {
+      font-size: 14px;
+      color: var(--text-secondary);
+      margin-bottom: 2px;
+    }
+
+    .status-value {
+      font-size: 14px;
+      color: var(--text-primary);
+      margin-bottom: 8px;
+    }
+
+    .progress-bar {
+      height: 8px;
+      background: var(--bg-input);
+      border-radius: 4px;
+      margin-bottom: 8px;
+    }
+
+    .progress-fill {
+      height: 100%;
+      background: var(--bambu-green);
+      border-radius: 4px;
+    }
+
+    .ready-text {
+      font-size: 12px;
+      color: var(--text-secondary);
+    }
+
+    /* Temperature Grid */
+    .temp-grid {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      gap: 8px;
+      margin-bottom: 12px;
+    }
+
+    .temp-card {
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 8px;
+      text-align: center;
+    }
+
+    .temp-icon {
+      width: 16px;
+      height: 16px;
+      margin: 0 auto 4px;
+    }
+
+    .temp-label {
+      font-size: 11px;
+      color: var(--text-secondary);
+      margin-bottom: 2px;
+    }
+
+    .temp-value {
+      font-size: 14px;
+      color: var(--text-primary);
+      font-weight: 500;
+    }
+
+    /* AMS Section */
+    .ams-section {
+      margin-top: 12px;
+    }
+
+    /* Current stacked layout */
+    .ams-stacked {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+    }
+
+    .ams-row {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 8px 10px;
+    }
+
+    .ams-icon-wrapper {
+      flex-shrink: 0;
+    }
+
+    .filament-info {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .ams-label {
+      font-size: 11px;
+      font-weight: 500;
+      color: var(--text-muted);
+    }
+
+    .filament-types {
+      font-size: 10px;
+      color: var(--text-secondary);
+      margin-top: 1px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .filament-fills {
+      font-size: 9px;
+      color: var(--text-muted);
+      margin-top: 1px;
+    }
+
+    .ams-stats {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-shrink: 0;
+    }
+
+    .stat {
+      display: flex;
+      align-items: center;
+      gap: 3px;
+      font-size: 11px;
+      font-weight: 500;
+    }
+
+    .stat svg {
+      width: 12px;
+      height: 12px;
+    }
+
+    .stat-good { color: var(--humidity-good); }
+    .stat-fair { color: var(--humidity-fair); }
+    .stat-bad { color: var(--humidity-bad); }
+    .stat-neutral { color: var(--text-secondary); }
+
+    /* Smart plug section */
+    .smart-plug-section {
+      margin-top: 16px;
+      padding-top: 16px;
+      border-top: 1px solid var(--border-color);
+    }
+
+    .plug-row {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+
+    .plug-icon {
+      width: 16px;
+      height: 16px;
+      color: var(--text-secondary);
+    }
+
+    .plug-name {
+      font-size: 14px;
+      color: var(--text-primary);
+    }
+
+    .plug-badge {
+      font-size: 11px;
+      padding: 2px 6px;
+      border-radius: 4px;
+      font-weight: 500;
+    }
+
+    .plug-badge.on {
+      background: var(--bambu-green-bg);
+      color: var(--bambu-green);
+    }
+
+    .plug-power {
+      font-size: 12px;
+      color: #facc15;
+      font-weight: 500;
+    }
+
+    .plug-controls {
+      margin-left: auto;
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .plug-btn {
+      font-size: 11px;
+      padding: 4px 8px;
+      border-radius: 4px;
+      border: none;
+      cursor: pointer;
+      font-weight: 500;
+    }
+
+    .plug-btn.on {
+      background: var(--bambu-green-bg);
+      color: var(--bambu-green);
+    }
+
+    .plug-btn.off {
+      background: var(--bg-input);
+      color: var(--text-secondary);
+    }
+
+    .auto-off-toggle {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      font-size: 11px;
+      color: var(--text-secondary);
+    }
+
+    .toggle-switch {
+      width: 32px;
+      height: 18px;
+      background: var(--bg-input);
+      border-radius: 9px;
+      position: relative;
+    }
+
+    .toggle-switch.active {
+      background: var(--bambu-green);
+    }
+
+    .toggle-switch::after {
+      content: '';
+      position: absolute;
+      width: 14px;
+      height: 14px;
+      background: white;
+      border-radius: 50%;
+      top: 2px;
+      left: 2px;
+      transition: transform 0.2s;
+    }
+
+    .toggle-switch.active::after {
+      transform: translateX(14px);
+    }
+
+    .plug-footer {
+      margin-top: 8px;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+
+    .plug-ip {
+      font-size: 11px;
+      color: var(--text-muted);
+    }
+
+    .plug-actions {
+      margin-left: auto;
+      display: flex;
+      gap: 4px;
+    }
+
+    .action-btn {
+      width: 28px;
+      height: 28px;
+      border-radius: 4px;
+      border: none;
+      background: var(--bg-input);
+      color: var(--text-secondary);
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .action-btn svg {
+      width: 14px;
+      height: 14px;
+    }
+
+    /* Comparison layout */
+    .comparison {
+      display: flex;
+      gap: 32px;
+      flex-wrap: wrap;
+      align-items: flex-start;
+    }
+
+    .comparison-section {
+      display: flex;
+      flex-direction: column;
+    }
+
+    .section-label {
+      display: inline-block;
+      font-size: 11px;
+      font-weight: 600;
+      padding: 4px 10px;
+      border-radius: 4px;
+      margin-bottom: 12px;
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+      width: fit-content;
+    }
+
+    .section-label.current {
+      background: var(--text-muted);
+      color: var(--bg-page);
+    }
+
+    .section-label.new {
+      background: var(--bambu-green);
+      color: white;
+    }
+
+    /* NEW 2-Column Grid Layout */
+    .ams-grid {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 8px;
+    }
+
+    .ams-card {
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 8px;
+    }
+
+    .ams-card-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-bottom: 6px;
+    }
+
+    .ams-card-left {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .ams-card-stats {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .ams-card-stats .stat {
+      font-size: 10px;
+    }
+
+    .ams-card-stats .stat svg {
+      width: 10px;
+      height: 10px;
+    }
+
+    .slots-grid {
+      display: grid;
+      grid-template-columns: repeat(4, 1fr);
+      gap: 4px;
+    }
+
+    .slot {
+      background: var(--bg-input);
+      border-radius: 4px;
+      padding: 4px 2px;
+      text-align: center;
+    }
+
+    .slot-color {
+      width: 14px;
+      height: 14px;
+      border-radius: 50%;
+      margin: 0 auto 2px;
+      border: 1px solid rgba(255, 255, 255, 0.1);
+    }
+
+    .slot-color.empty {
+      background: transparent;
+      border: 1px dashed var(--text-muted);
+    }
+
+    .slot-type {
+      font-size: 8px;
+      color: var(--text-muted);
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .slot-fill {
+      font-size: 8px;
+      color: var(--text-muted);
+      opacity: 0.7;
+    }
+
+    /* Row 3: HT + External (half-size) */
+    .ams-row-small {
+      display: grid;
+      grid-template-columns: repeat(4, 1fr);
+      gap: 8px;
+      margin-top: 8px;
+    }
+
+    .ams-card-small {
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 6px 8px;
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .ams-card-small .small-info {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .ams-card-small .ams-label {
+      font-size: 10px;
+    }
+
+    .ams-card-small .slot-type {
+      font-size: 9px;
+    }
+
+    .external-spool {
+      width: 20px;
+      height: 20px;
+      border-radius: 50%;
+      border: 2px solid rgba(255, 255, 255, 0.15);
+      flex-shrink: 0;
+    }
+
+    .note {
+      font-size: 11px;
+      color: var(--text-muted);
+      margin-top: 12px;
+      padding: 8px;
+      background: var(--bg-section);
+      border-radius: 6px;
+    }
+  </style>
+</head>
+<body>
+  <div class="page-header">
+    <h1 class="page-title">AMS Section Redesign</h1>
+    <p class="page-subtitle">Current stacked layout vs. new 2-column grid</p>
+  </div>
+
+  <div class="comparison">
+    <!-- CURRENT LAYOUT -->
+    <div class="comparison-section">
+      <span class="section-label current">Current</span>
+
+      <div class="printer-card">
+        <!-- Header -->
+        <div class="card-header">
+          <div class="printer-image">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+              <rect x="4" y="4" width="16" height="16" rx="2"/>
+              <path d="M4 10h16"/>
+              <path d="M10 10v10"/>
+            </svg>
+          </div>
+          <div class="printer-details">
+            <div class="printer-name-row">
+              <span class="printer-name">H2D-1</span>
+              <button class="menu-btn">
+                <svg viewBox="0 0 24 24" fill="currentColor">
+                  <circle cx="12" cy="5" r="1.5"/>
+                  <circle cx="12" cy="12" r="1.5"/>
+                  <circle cx="12" cy="19" r="1.5"/>
+                </svg>
+              </button>
+            </div>
+            <div class="printer-model">H2D • 0.4mm • 632h</div>
+          </div>
+        </div>
+
+        <!-- Badges -->
+        <div class="badges-row">
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
+              <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
+            </svg>
+            Connected
+          </span>
+          <span class="badge badge-green">-53dBm</span>
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M12 9v2m0 4h.01"/>
+              <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
+            </svg>
+            OK
+          </span>
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
+            </svg>
+            OK
+          </span>
+        </div>
+
+        <!-- Status -->
+        <div class="status-section">
+          <div class="status-row">
+            <div class="cover-placeholder">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+                <rect x="3" y="3" width="18" height="18" rx="2"/>
+                <circle cx="8.5" cy="8.5" r="1.5"/>
+                <path d="M21 15l-5-5L5 21"/>
+              </svg>
+            </div>
+            <div class="status-info">
+              <div class="status-label">Status</div>
+              <div class="status-value">Idle</div>
+              <div class="progress-bar"></div>
+              <div class="ready-text">Ready to print</div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Temperatures -->
+        <div class="temp-grid">
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#f97316" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Left / Right</div>
+            <div class="temp-value">20°C / 19°C</div>
+          </div>
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Bed</div>
+            <div class="temp-value">20°C</div>
+          </div>
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Chamber</div>
+            <div class="temp-value">21°C</div>
+          </div>
+        </div>
+
+        <!-- AMS Section - CURRENT STACKED -->
+        <div class="ams-section">
+          <div class="ams-stacked">
+            <!-- AMS-A -->
+            <div class="ams-row">
+              <div class="ams-icon-wrapper">
+                <svg width="56" height="34" viewBox="0 0 52 32" fill="none">
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z" fill="#2F2E33"/>
+                  <rect x="9.5" y="8" width="6" height="16" fill="#e53935"/>
+                  <rect x="18.5" y="8" width="6" height="16" fill="#1e88e5"/>
+                  <rect x="27.5" y="8" width="6" height="16" fill="#43a047"/>
+                  <rect x="36.5" y="8" width="6" height="16" fill="#f5f5f5"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z" fill="#767676"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z" fill="#BFBFBF"/>
+                </svg>
+              </div>
+              <div class="filament-info">
+                <div class="ams-label">AMS-A</div>
+                <div class="filament-types">PLA Basic · PETG HF · PLA Basic · PLA Basic</div>
+                <div class="filament-fills">71% · 50% · 87% · 23%</div>
+              </div>
+              <div class="ams-stats">
+                <div class="stat stat-good">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  21%
+                </div>
+                <div class="stat stat-neutral">20.7°C</div>
+              </div>
+            </div>
+
+            <!-- AMS-B -->
+            <div class="ams-row">
+              <div class="ams-icon-wrapper">
+                <svg width="56" height="34" viewBox="0 0 52 32" fill="none">
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z" fill="#2F2E33"/>
+                  <rect x="9.5" y="8" width="6" height="16" fill="#9c27b0"/>
+                  <rect x="18.5" y="8" width="6" height="16" fill="#ff9800"/>
+                  <rect x="27.5" y="8" width="6" height="16" fill="#fdd835"/>
+                  <rect x="36.5" y="8" width="6" height="16" fill="#212121"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z" fill="#767676"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z" fill="#BFBFBF"/>
+                </svg>
+              </div>
+              <div class="filament-info">
+                <div class="ams-label">AMS-B</div>
+                <div class="filament-types">PETG HF · PLA · PLA-S · PLA-S</div>
+                <div class="filament-fills">45% · 92% · 15% · 68%</div>
+              </div>
+              <div class="ams-stats">
+                <div class="stat stat-good">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  16%
+                </div>
+                <div class="stat stat-neutral">22.7°C</div>
+              </div>
+            </div>
+
+            <!-- AMS-C -->
+            <div class="ams-row">
+              <div class="ams-icon-wrapper">
+                <svg width="56" height="34" viewBox="0 0 52 32" fill="none">
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z" fill="#2F2E33"/>
+                  <rect x="9.5" y="8" width="6" height="16" fill="#00bcd4"/>
+                  <rect x="18.5" y="8" width="6" height="16" fill="#e91e63"/>
+                  <rect x="27.5" y="8" width="6" height="16" fill="#9e9e9e"/>
+                  <rect x="36.5" y="8" width="6" height="16" fill="#f48fb1"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z" fill="#767676"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z" fill="#BFBFBF"/>
+                </svg>
+              </div>
+              <div class="filament-info">
+                <div class="ams-label">AMS-C</div>
+                <div class="filament-types">PLA-S · PLA-S · PETG · PLA</div>
+                <div class="filament-fills">33% · 78% · 55% · 41%</div>
+              </div>
+              <div class="ams-stats">
+                <div class="stat stat-good">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  16%
+                </div>
+                <div class="stat stat-neutral">22.9°C</div>
+              </div>
+            </div>
+
+            <!-- HT-A -->
+            <div class="ams-row">
+              <div class="ams-icon-wrapper">
+                <svg width="56" height="56" viewBox="0 0 21 21" fill="none">
+                  <rect x="8.3" y="5.2" width="3.8" height="5.1" fill="none" stroke="#666" stroke-dasharray="2 1.5" rx="0.3"/>
+                  <path d="M5.88312 4.68555C5.88312 4.13326 6.33083 3.68555 6.88312 3.68555H13.5059C14.0582 3.68555 14.5059 4.13326 14.5059 4.68555V10.3887H5.88312V4.68555Z" stroke="#6B6B6B"/>
+                  <rect x="3.8725" y="10.3887" width="12.7037" height="7.55371" rx="1.2" stroke="#6B6B6B"/>
+                  <path d="M8.21991 5.65234C8.21991 5.3762 8.44377 5.15234 8.71991 5.15234H11.7288C12.005 5.15234 12.2288 5.3762 12.2288 5.65234V10.3887H8.21991V5.65234Z" stroke="#6B6B6B"/>
+                </svg>
+              </div>
+              <div class="filament-info">
+                <div class="ams-label">HT-A</div>
+                <div class="filament-types">—</div>
+              </div>
+              <div class="ams-stats">
+                <div class="stat stat-fair">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  47%
+                </div>
+                <div class="stat stat-neutral">19.7°C</div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Smart Plug -->
+        <div class="smart-plug-section">
+          <div class="plug-row">
+            <svg class="plug-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
+            </svg>
+            <span class="plug-name">bamnbuswitch3</span>
+            <span class="plug-badge on">ON</span>
+            <span class="plug-power">18W</span>
+            <div class="plug-controls">
+              <button class="plug-btn on">On</button>
+              <button class="plug-btn off">Off</button>
+              <div class="auto-off-toggle">
+                Auto-off
+                <div class="toggle-switch"></div>
+              </div>
+            </div>
+          </div>
+          <div class="plug-footer">
+            <span class="plug-ip">192.168.255.133<br/>00488B540200427</span>
+            <div class="plug-actions">
+              <button class="action-btn">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>
+                </svg>
+              </button>
+              <button class="action-btn">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
+                </svg>
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <div class="note">4 AMS rows + 1 HT row = 5 rows in AMS section</div>
+      </div>
+    </div>
+
+    <!-- NEW LAYOUT -->
+    <div class="comparison-section">
+      <span class="section-label new">New 2-Column</span>
+
+      <div class="printer-card">
+        <!-- Header (same as current) -->
+        <div class="card-header">
+          <div class="printer-image">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+              <rect x="4" y="4" width="16" height="16" rx="2"/>
+              <path d="M4 10h16"/>
+              <path d="M10 10v10"/>
+            </svg>
+          </div>
+          <div class="printer-details">
+            <div class="printer-name-row">
+              <span class="printer-name">H2D-1</span>
+              <button class="menu-btn">
+                <svg viewBox="0 0 24 24" fill="currentColor">
+                  <circle cx="12" cy="5" r="1.5"/>
+                  <circle cx="12" cy="12" r="1.5"/>
+                  <circle cx="12" cy="19" r="1.5"/>
+                </svg>
+              </button>
+            </div>
+            <div class="printer-model">H2D • 0.4mm • 632h</div>
+          </div>
+        </div>
+
+        <!-- Badges (same) -->
+        <div class="badges-row">
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
+              <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
+            </svg>
+            Connected
+          </span>
+          <span class="badge badge-green">-53dBm</span>
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M12 9v2m0 4h.01"/>
+              <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
+            </svg>
+            OK
+          </span>
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
+            </svg>
+            OK
+          </span>
+        </div>
+
+        <!-- Status (same) -->
+        <div class="status-section">
+          <div class="status-row">
+            <div class="cover-placeholder">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+                <rect x="3" y="3" width="18" height="18" rx="2"/>
+                <circle cx="8.5" cy="8.5" r="1.5"/>
+                <path d="M21 15l-5-5L5 21"/>
+              </svg>
+            </div>
+            <div class="status-info">
+              <div class="status-label">Status</div>
+              <div class="status-value">Idle</div>
+              <div class="progress-bar"></div>
+              <div class="ready-text">Ready to print</div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Temperatures (same) -->
+        <div class="temp-grid">
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#f97316" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Left / Right</div>
+            <div class="temp-value">20°C / 19°C</div>
+          </div>
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Bed</div>
+            <div class="temp-value">20°C</div>
+          </div>
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Chamber</div>
+            <div class="temp-value">21°C</div>
+          </div>
+        </div>
+
+        <!-- AMS Section - NEW 2-COLUMN GRID -->
+        <div class="ams-section">
+          <!-- Row 1-2: Up to 4x AMS -->
+          <div class="ams-grid">
+            <!-- AMS-A -->
+            <div class="ams-card">
+              <div class="ams-card-header">
+                <div class="ams-card-left">
+                  <svg width="36" height="22" viewBox="0 0 36 22" fill="none">
+                    <rect x="1" y="1" width="34" height="20" rx="2" fill="#2F2E33"/>
+                    <rect x="5" y="5" width="4" height="12" fill="#e53935"/>
+                    <rect x="11" y="5" width="4" height="12" fill="#1e88e5"/>
+                    <rect x="17" y="5" width="4" height="12" fill="#43a047"/>
+                    <rect x="23" y="5" width="4" height="12" fill="#f5f5f5"/>
+                    <rect x="29" y="5" width="2" height="12" fill="#767676"/>
+                  </svg>
+                  <span class="ams-label">AMS-A</span>
+                </div>
+                <div class="ams-card-stats">
+                  <div class="stat stat-good">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                    </svg>
+                    21%
+                  </div>
+                  <div class="stat stat-neutral">20.6°</div>
+                </div>
+              </div>
+              <div class="slots-grid">
+                <div class="slot">
+                  <div class="slot-color" style="background: #e53935;"></div>
+                  <div class="slot-type">PLA Basic</div>
+                  <div class="slot-fill">71%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #1e88e5;"></div>
+                  <div class="slot-type">PETG HF</div>
+                  <div class="slot-fill">50%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #43a047;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">87%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #f5f5f5;"></div>
+                  <div class="slot-type">PLA Basic</div>
+                  <div class="slot-fill">23%</div>
+                </div>
+              </div>
+            </div>
+
+            <!-- AMS-B -->
+            <div class="ams-card">
+              <div class="ams-card-header">
+                <div class="ams-card-left">
+                  <svg width="36" height="22" viewBox="0 0 36 22" fill="none">
+                    <rect x="1" y="1" width="34" height="20" rx="2" fill="#2F2E33"/>
+                    <rect x="5" y="5" width="4" height="12" fill="#9c27b0"/>
+                    <rect x="11" y="5" width="4" height="12" fill="#ff9800"/>
+                    <rect x="17" y="5" width="4" height="12" fill="#fdd835"/>
+                    <rect x="23" y="5" width="4" height="12" fill="#212121"/>
+                    <rect x="29" y="5" width="2" height="12" fill="#767676"/>
+                  </svg>
+                  <span class="ams-label">AMS-B</span>
+                </div>
+                <div class="ams-card-stats">
+                  <div class="stat stat-good">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                    </svg>
+                    16%
+                  </div>
+                  <div class="stat stat-neutral">20.7°</div>
+                </div>
+              </div>
+              <div class="slots-grid">
+                <div class="slot">
+                  <div class="slot-color" style="background: #9c27b0;"></div>
+                  <div class="slot-type">PETG HF</div>
+                  <div class="slot-fill">45%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #ff9800;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">92%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #fdd835;"></div>
+                  <div class="slot-type">PLA-S</div>
+                  <div class="slot-fill">15%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #212121;"></div>
+                  <div class="slot-type">PLA-S</div>
+                  <div class="slot-fill">68%</div>
+                </div>
+              </div>
+            </div>
+
+            <!-- AMS-C -->
+            <div class="ams-card">
+              <div class="ams-card-header">
+                <div class="ams-card-left">
+                  <svg width="36" height="22" viewBox="0 0 36 22" fill="none">
+                    <rect x="1" y="1" width="34" height="20" rx="2" fill="#2F2E33"/>
+                    <rect x="5" y="5" width="4" height="12" fill="#00bcd4"/>
+                    <rect x="11" y="5" width="4" height="12" fill="#e91e63"/>
+                    <rect x="17" y="5" width="4" height="12" fill="#9e9e9e"/>
+                    <rect x="23" y="5" width="4" height="12" fill="#f48fb1"/>
+                    <rect x="29" y="5" width="2" height="12" fill="#767676"/>
+                  </svg>
+                  <span class="ams-label">AMS-C</span>
+                </div>
+                <div class="ams-card-stats">
+                  <div class="stat stat-good">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                    </svg>
+                    17%
+                  </div>
+                  <div class="stat stat-neutral">22.0°</div>
+                </div>
+              </div>
+              <div class="slots-grid">
+                <div class="slot">
+                  <div class="slot-color" style="background: #00bcd4;"></div>
+                  <div class="slot-type">PLA-S</div>
+                  <div class="slot-fill">33%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #e91e63;"></div>
+                  <div class="slot-type">PLA-S</div>
+                  <div class="slot-fill">78%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #9e9e9e;"></div>
+                  <div class="slot-type">PETG</div>
+                  <div class="slot-fill">55%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #f48fb1;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">41%</div>
+                </div>
+              </div>
+            </div>
+
+            <!-- AMS-D -->
+            <div class="ams-card">
+              <div class="ams-card-header">
+                <div class="ams-card-left">
+                  <svg width="36" height="22" viewBox="0 0 36 22" fill="none">
+                    <rect x="1" y="1" width="34" height="20" rx="2" fill="#2F2E33"/>
+                    <rect x="5" y="5" width="4" height="12" fill="#8bc34a"/>
+                    <rect x="11" y="5" width="4" height="12" fill="#ff5722"/>
+                    <rect x="17" y="5" width="4" height="12" fill="#607d8b"/>
+                    <rect x="23" y="5" width="4" height="12" fill="#795548"/>
+                    <rect x="29" y="5" width="2" height="12" fill="#767676"/>
+                  </svg>
+                  <span class="ams-label">AMS-D</span>
+                </div>
+                <div class="ams-card-stats">
+                  <div class="stat stat-good">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                    </svg>
+                    9%
+                  </div>
+                  <div class="stat stat-neutral">21.2°</div>
+                </div>
+              </div>
+              <div class="slots-grid">
+                <div class="slot">
+                  <div class="slot-color" style="background: #8bc34a;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">88%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #ff5722;"></div>
+                  <div class="slot-type">ABS</div>
+                  <div class="slot-fill">62%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #607d8b;"></div>
+                  <div class="slot-type">PETG</div>
+                  <div class="slot-fill">29%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #795548;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">95%</div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <!-- Row 3: HT + External (half-size, 4 across) -->
+          <div class="ams-row-small">
+            <!-- HT-A -->
+            <div class="ams-card-small">
+              <svg width="20" height="20" viewBox="0 0 21 21" fill="none">
+                <rect x="6" y="4" width="9" height="7" rx="1" fill="#2F2E33" stroke="#6B6B6B"/>
+                <rect x="4" y="11" width="13" height="6" rx="1" stroke="#6B6B6B"/>
+                <circle cx="10.5" cy="7.5" r="2" fill="none" stroke="#666" stroke-dasharray="2 1"/>
+              </svg>
+              <div class="small-info">
+                <div class="ams-label">HT-A</div>
+                <div class="slot-type">Empty</div>
+              </div>
+              <div class="ams-card-stats">
+                <div class="stat stat-fair">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  44%
+                </div>
+              </div>
+            </div>
+
+            <!-- HT-B -->
+            <div class="ams-card-small">
+              <svg width="20" height="20" viewBox="0 0 21 21" fill="none">
+                <rect x="6" y="4" width="9" height="7" rx="1" fill="#2F2E33" stroke="#6B6B6B"/>
+                <rect x="4" y="11" width="13" height="6" rx="1" stroke="#6B6B6B"/>
+                <circle cx="10.5" cy="7.5" r="2" fill="#00acc1"/>
+              </svg>
+              <div class="small-info">
+                <div class="ams-label">HT-B</div>
+                <div class="slot-type">PA-CF</div>
+              </div>
+              <div class="ams-card-stats">
+                <div class="stat stat-good">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  12%
+                </div>
+              </div>
+            </div>
+
+            <!-- External 1 -->
+            <div class="ams-card-small">
+              <div class="external-spool" style="background: #b0bec5;"></div>
+              <div class="small-info">
+                <div class="ams-label">Ext-1</div>
+                <div class="slot-type">PLA</div>
+              </div>
+            </div>
+
+            <!-- External 2 -->
+            <div class="ams-card-small">
+              <div class="external-spool" style="background: #ffeb3b;"></div>
+              <div class="small-info">
+                <div class="ams-label">Ext-2</div>
+                <div class="slot-type">TPU</div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Smart Plug (same as current) -->
+        <div class="smart-plug-section">
+          <div class="plug-row">
+            <svg class="plug-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
+            </svg>
+            <span class="plug-name">bamnbuswitch3</span>
+            <span class="plug-badge on">ON</span>
+            <span class="plug-power">18W</span>
+            <div class="plug-controls">
+              <button class="plug-btn on">On</button>
+              <button class="plug-btn off">Off</button>
+              <div class="auto-off-toggle">
+                Auto-off
+                <div class="toggle-switch"></div>
+              </div>
+            </div>
+          </div>
+          <div class="plug-footer">
+            <span class="plug-ip">192.168.255.133<br/>00488B540200427</span>
+            <div class="plug-actions">
+              <button class="action-btn">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>
+                </svg>
+              </button>
+              <button class="action-btn">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
+                </svg>
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <div class="note">3 rows total: Row 1-2 for 4x AMS, Row 3 for 2x HT + 2x Ext (half-size)</div>
+      </div>
+    </div>
+  </div>
+</body>
+</html>

+ 2 - 0
frontend/src/__tests__/components/AMSHistoryModal.test.tsx

@@ -12,6 +12,8 @@ import { api } from '../../api/client';
 vi.mock('../../api/client', () => ({
   api: {
     getAMSHistory: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
   },
 }));
 

+ 4 - 0
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -16,6 +16,10 @@ import { VirtualPrinterSettings } from '../../components/VirtualPrinterSettings'
 
 // Mock the API client
 vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
   virtualPrinterApi: {
     getSettings: vi.fn(),
     updateSettings: vi.fn(),

+ 2 - 0
frontend/src/__tests__/pages/SystemInfoPage.test.tsx

@@ -12,6 +12,8 @@ import { api } from '../../api/client';
 vi.mock('../../api/client', () => ({
   api: {
     getSystemInfo: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
   },
 }));
 

+ 16 - 2
frontend/src/api/client.ts

@@ -21,7 +21,7 @@ async function request<T>(
     throw new Error(message);
   }
 
-  return response.json();
+  return await response.json();
 }
 
 // Printer types
@@ -55,7 +55,8 @@ export interface AMSTray {
   tray_id_name: string | null;  // Bambu filament ID like "A00-Y2" (can decode to color)
   tray_info_idx: string | null;  // Filament preset ID like "GFA00" - maps to cloud setting_id
   remain: number;
-  k: number | null;  // Pressure advance value
+  k: number | null;  // Pressure advance value (from tray or K-profile lookup)
+  cali_idx: number | null;  // Calibration index for K-profile lookup
   tag_uid: string | null;  // RFID tag UID (any tag)
   tray_uuid: string | null;  // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
   nozzle_temp_min: number | null;  // Min nozzle temperature
@@ -544,6 +545,14 @@ export interface AppSettings {
   default_printer_id: number | null;
   // Telemetry
   telemetry_enabled: boolean;
+  // Dark mode theme settings
+  dark_style: 'classic' | 'glow' | 'vibrant';
+  dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
+  dark_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
+  // Light mode theme settings
+  light_style: 'classic' | 'glow' | 'vibrant';
+  light_background: 'neutral' | 'warm' | 'cool';
+  light_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -1756,6 +1765,11 @@ export const api = {
     request<FieldDefinitionsResponse>(`/cloud/fields/${presetType}`),
   getAllCloudFields: () =>
     request<Record<string, FieldDefinitionsResponse>>('/cloud/fields'),
+  getFilamentInfo: (settingIds: string[]) =>
+    request<Record<string, { name: string; k: number | null }>>('/cloud/filament-info', {
+      method: 'POST',
+      body: JSON.stringify(settingIds),
+    }),
 
   // Smart Plugs
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),

+ 2 - 2
frontend/src/components/AMSHistoryModal.tsx

@@ -52,10 +52,10 @@ export function AMSHistoryModal({
   thresholds,
 }: AMSHistoryModalProps) {
   const { t } = useTranslation();
-  const { theme } = useTheme();
+  const { mode: themeMode } = useTheme();
   const [timeRange, setTimeRange] = useState<TimeRange>('24h');
   const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
-  const isDark = theme === 'dark';
+  const isDark = themeMode === 'dark';
 
   // Close on Escape key
   useEffect(() => {

+ 3 - 3
frontend/src/components/AddExternalLinkModal.tsx

@@ -14,7 +14,7 @@ interface AddExternalLinkModalProps {
 
 export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {
   const queryClient = useQueryClient();
-  const { theme } = useTheme();
+  const { mode } = useTheme();
   const isEditing = !!link;
   const fileInputRef = useRef<HTMLInputElement>(null);
 
@@ -166,7 +166,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
           <div className="flex items-center gap-3">
             <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
               {useCustomIcon && customIconPreview ? (
-                <img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
+                <img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
               ) : (
                 <PresetIcon className="w-5 h-5" />
               )}
@@ -233,7 +233,7 @@ export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProp
                 />
                 {useCustomIcon && customIconPreview ? (
                   <div className="flex items-center gap-2">
-                    <img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
+                    <img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} />
                     <button
                       type="button"
                       onClick={handleRemoveCustomIcon}

+ 1 - 1
frontend/src/components/Card.tsx

@@ -10,7 +10,7 @@ interface CardProps {
 export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
   return (
     <div
-      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary ${className}`}
+      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${className}`}
       onClick={onClick}
       onContextMenu={onContextMenu}
     >

+ 267 - 0
frontend/src/components/FilamentHoverCard.tsx

@@ -0,0 +1,267 @@
+import { useState, useRef, useEffect, type ReactNode } from 'react';
+import { Droplets } from 'lucide-react';
+
+interface FilamentData {
+  vendor: 'Bambu Lab' | 'Generic';
+  profile: string;
+  colorName: string;
+  colorHex: string | null;
+  kFactor: string;
+  fillLevel: number | null; // null = unknown
+}
+
+interface FilamentHoverCardProps {
+  data: FilamentData;
+  children: ReactNode;
+  disabled?: boolean;
+  className?: string;
+}
+
+/**
+ * A hover card that displays filament details when hovering over AMS slots.
+ * Replaces the basic browser tooltip with a styled popover.
+ */
+export function FilamentHoverCard({ data, children, disabled, className = '' }: FilamentHoverCardProps) {
+  const [isVisible, setIsVisible] = useState(false);
+  const [position, setPosition] = useState<'top' | 'bottom'>('top');
+  const triggerRef = useRef<HTMLDivElement>(null);
+  const cardRef = useRef<HTMLDivElement>(null);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  // Calculate position when showing
+  useEffect(() => {
+    if (isVisible && triggerRef.current && cardRef.current) {
+      const triggerRect = triggerRef.current.getBoundingClientRect();
+      const cardHeight = cardRef.current.offsetHeight;
+      const spaceAbove = triggerRect.top;
+      const spaceBelow = window.innerHeight - triggerRect.bottom;
+
+      // Prefer top, but flip to bottom if not enough space
+      if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
+        setPosition('bottom');
+      } else {
+        setPosition('top');
+      }
+    }
+  }, [isVisible]);
+
+  const handleMouseEnter = () => {
+    if (disabled) return;
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    // Small delay to prevent flicker on quick mouse movements
+    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
+  };
+
+  const handleMouseLeave = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
+  };
+
+  // Cleanup timeout on unmount
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    };
+  }, []);
+
+  // Get fill bar color based on percentage
+  const getFillColor = (fill: number): string => {
+    if (fill <= 15) return '#ef4444'; // red
+    if (fill <= 30) return '#f97316'; // orange
+    if (fill <= 50) return '#eab308'; // yellow
+    return '#22c55e'; // green
+  };
+
+  // Determine if color is light (for text contrast on swatch)
+  const isLightColor = (hex: string | null): boolean => {
+    if (!hex) return false;
+    const cleanHex = hex.replace('#', '');
+    const r = parseInt(cleanHex.slice(0, 2), 16);
+    const g = parseInt(cleanHex.slice(2, 4), 16);
+    const b = parseInt(cleanHex.slice(4, 6), 16);
+    const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+    return luminance > 0.6;
+  };
+
+  const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
+
+  return (
+    <div
+      ref={triggerRef}
+      className={`relative ${className}`}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      {children}
+
+      {/* Hover Card */}
+      {isVisible && (
+        <div
+          ref={cardRef}
+          className={`
+            absolute left-1/2 -translate-x-1/2 z-50
+            ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
+            animate-in fade-in-0 zoom-in-95 duration-150
+          `}
+          style={{
+            // Ensure card doesn't go off-screen horizontally
+            maxWidth: 'calc(100vw - 24px)',
+          }}
+        >
+          {/* Card container */}
+          <div className="
+            w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary
+            rounded-lg shadow-xl overflow-hidden
+            backdrop-blur-sm
+          ">
+            {/* Color swatch header - the hero element */}
+            <div
+              className="h-12 relative overflow-hidden"
+              style={{
+                backgroundColor: colorHex || '#3d3d3d',
+              }}
+            >
+              {/* Subtle gradient overlay for depth */}
+              <div className="absolute inset-0 bg-gradient-to-b from-white/10 to-transparent" />
+
+              {/* Color name on swatch */}
+              <div className={`
+                absolute inset-0 flex items-center justify-center
+                font-semibold text-sm tracking-wide
+                ${isLightColor(colorHex) ? 'text-black/80' : 'text-white/90'}
+              `}>
+                {data.colorName}
+              </div>
+
+              {/* Vendor badge - solid background for visibility on any color */}
+              <div className={`
+                absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider
+                ${data.vendor === 'Bambu Lab'
+                  ? 'bg-black/60 text-white'
+                  : 'bg-black/50 text-white/90'}
+              `}>
+                {data.vendor === 'Bambu Lab' ? 'BBL' : 'GEN'}
+              </div>
+            </div>
+
+            {/* Details section */}
+            <div className="p-3 space-y-2.5">
+              {/* Profile name */}
+              <div className="flex items-center justify-between">
+                <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                  Profile
+                </span>
+                <span className="text-xs text-white font-semibold truncate max-w-[120px]">
+                  {data.profile}
+                </span>
+              </div>
+
+              {/* K Factor */}
+              <div className="flex items-center justify-between">
+                <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                  K Factor
+                </span>
+                <span className="text-xs text-bambu-green font-mono font-bold">
+                  {data.kFactor}
+                </span>
+              </div>
+
+              {/* Fill Level */}
+              <div className="space-y-1">
+                <div className="flex items-center justify-between">
+                  <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1">
+                    <Droplets className="w-3 h-3" />
+                    Fill
+                  </span>
+                  <span className="text-xs text-white font-semibold">
+                    {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
+                  </span>
+                </div>
+                {/* Fill bar */}
+                <div className="h-1.5 bg-black/40 rounded-full overflow-hidden">
+                  {data.fillLevel !== null ? (
+                    <div
+                      className="h-full rounded-full transition-all duration-300"
+                      style={{
+                        width: `${data.fillLevel}%`,
+                        backgroundColor: getFillColor(data.fillLevel),
+                      }}
+                    />
+                  ) : (
+                    <div className="h-full w-full bg-bambu-gray/30 rounded-full" />
+                  )}
+                </div>
+              </div>
+            </div>
+          </div>
+
+          {/* Arrow pointer */}
+          <div
+            className={`
+              absolute left-1/2 -translate-x-1/2 w-0 h-0
+              border-l-[6px] border-l-transparent
+              border-r-[6px] border-r-transparent
+              ${position === 'top'
+                ? 'top-full border-t-[6px] border-t-bambu-dark-tertiary'
+                : 'bottom-full border-b-[6px] border-b-bambu-dark-tertiary'}
+            `}
+          />
+        </div>
+      )}
+    </div>
+  );
+}
+
+/**
+ * Wrapper for empty slots - just shows "Empty" on hover
+ */
+export function EmptySlotHoverCard({ children, className = '' }: { children: ReactNode; className?: string }) {
+  const [isVisible, setIsVisible] = useState(false);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const handleMouseEnter = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
+  };
+
+  const handleMouseLeave = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
+  };
+
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    };
+  }, []);
+
+  return (
+    <div
+      className={`relative ${className}`}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      {children}
+
+      {isVisible && (
+        <div className="
+          absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-50
+          animate-in fade-in-0 zoom-in-95 duration-150
+        ">
+          <div className="
+            px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary
+            rounded-md shadow-lg text-xs text-bambu-gray whitespace-nowrap
+          ">
+            Empty slot
+          </div>
+          <div className="
+            absolute left-1/2 -translate-x-1/2 top-full w-0 h-0
+            border-l-[5px] border-l-transparent
+            border-r-[5px] border-r-transparent
+            border-t-[5px] border-t-bambu-dark-tertiary
+          " />
+        </div>
+      )}
+    </div>
+  );
+}

+ 10 - 10
frontend/src/components/Layout.tsx

@@ -64,7 +64,7 @@ export function setDefaultView(path: string) {
 export function Layout() {
   const navigate = useNavigate();
   const location = useLocation();
-  const { theme, toggleTheme } = useTheme();
+  const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
   const isMobile = useIsMobile();
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
@@ -300,7 +300,7 @@ export function Layout() {
             <Menu className="w-6 h-6 text-white" />
           </button>
           <img
-            src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
+            src={mode === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
             alt="Bambuddy"
             className="h-8 ml-3"
           />
@@ -326,7 +326,7 @@ export function Layout() {
         {/* Logo */}
         <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
           <img
-            src={theme === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
+            src={mode === 'dark' ? '/img/bambuddy_logo_dark.png' : '/img/bambuddy_logo_light.png'}
             alt="Bambuddy"
             className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
           />
@@ -379,7 +379,7 @@ export function Layout() {
                         <img
                           src={`/api/v1/external-links/${link.id}/icon`}
                           alt=""
-                          className={`w-5 h-5 flex-shrink-0 ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
+                          className={`w-5 h-5 flex-shrink-0 ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
                         />
                       ) : (
                         LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
@@ -500,11 +500,11 @@ export function Layout() {
                   <Keyboard className="w-5 h-5" />
                 </button>
                 <button
-                  onClick={toggleTheme}
+                  onClick={toggleMode}
                   className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
+                  title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
                 >
-                  {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
+                  {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 </button>
               </div>
               {/* Bottom row: version */}
@@ -577,11 +577,11 @@ export function Layout() {
                 <Keyboard className="w-5 h-5" />
               </button>
               <button
-                onClick={toggleTheme}
+                onClick={toggleMode}
                 className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
+                title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
               >
-                {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
+                {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>
             </div>
           )}

+ 2 - 2
frontend/src/components/ModelViewerModal.tsx

@@ -4,6 +4,7 @@ import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { api } from '../api/client';
+import { openInSlicer } from '../utils/slicer';
 
 type ViewTab = '3d' | 'gcode';
 
@@ -55,11 +56,10 @@ export function ModelViewerModal({ archiveId, title, onClose }: ModelViewerModal
   }, [archiveId]);
 
   const handleOpenInSlicer = () => {
-    // Use bambustudioopen:// protocol like MakerWorld does
     // URL must include .3mf filename for Bambu Studio to recognize the format
     const filename = title || 'model';
     const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId, filename)}`;
-    window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+    openInSlicer(downloadUrl);
   };
 
   return (

+ 0 - 52
frontend/src/components/ThemeContext.tsx

@@ -1,52 +0,0 @@
-import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
-
-type Theme = 'light' | 'dark';
-
-interface ThemeContextType {
-  theme: Theme;
-  toggleTheme: () => void;
-  setTheme: (theme: Theme) => void;
-}
-
-const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
-
-export function ThemeProvider({ children }: { children: ReactNode }) {
-  const [theme, setThemeState] = useState<Theme>(() => {
-    const stored = localStorage.getItem('theme') as Theme | null;
-    if (stored) return stored;
-    // Default to dark theme
-    return 'dark';
-  });
-
-  useEffect(() => {
-    const root = document.documentElement;
-    if (theme === 'dark') {
-      root.classList.add('dark');
-    } else {
-      root.classList.remove('dark');
-    }
-    localStorage.setItem('theme', theme);
-  }, [theme]);
-
-  const toggleTheme = () => {
-    setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
-  };
-
-  const setTheme = (newTheme: Theme) => {
-    setThemeState(newTheme);
-  };
-
-  return (
-    <ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
-      {children}
-    </ThemeContext.Provider>
-  );
-}
-
-export function useTheme() {
-  const context = useContext(ThemeContext);
-  if (!context) {
-    throw new Error('useTheme must be used within a ThemeProvider');
-  }
-  return context;
-}

+ 149 - 21
frontend/src/contexts/ThemeContext.tsx

@@ -1,43 +1,171 @@
 import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
+import { api } from '../api/client';
 
-type Theme = 'light' | 'dark';
+type ThemeMode = 'light' | 'dark';
+type ThemeStyle = 'classic' | 'glow' | 'vibrant';
+type DarkBackground = 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
+type LightBackground = 'neutral' | 'warm' | 'cool';
+type ThemeAccent = 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
 
 interface ThemeContextType {
-  theme: Theme;
-  toggleTheme: () => void;
-  setTheme: (theme: Theme) => void;
+  mode: ThemeMode;
+  // Dark mode settings
+  darkStyle: ThemeStyle;
+  darkBackground: DarkBackground;
+  darkAccent: ThemeAccent;
+  // Light mode settings
+  lightStyle: ThemeStyle;
+  lightBackground: LightBackground;
+  lightAccent: ThemeAccent;
+  // Actions
+  toggleMode: () => void;
+  setMode: (mode: ThemeMode) => void;
+  setDarkStyle: (style: ThemeStyle) => void;
+  setDarkBackground: (background: DarkBackground) => void;
+  setDarkAccent: (accent: ThemeAccent) => void;
+  setLightStyle: (style: ThemeStyle) => void;
+  setLightBackground: (background: LightBackground) => void;
+  setLightAccent: (accent: ThemeAccent) => void;
 }
 
 const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
 
 export function ThemeProvider({ children }: { children: ReactNode }) {
-  const [theme, setThemeState] = useState<Theme>(() => {
-    const stored = localStorage.getItem('theme') as Theme | null;
-    if (stored) return stored;
-    // Default to dark theme
-    return 'dark';
+  // Mode
+  const [mode, setModeState] = useState<ThemeMode>(() => {
+    const stored = localStorage.getItem('theme-mode') as ThemeMode | null;
+    const legacy = localStorage.getItem('theme') as ThemeMode | null;
+    return stored || legacy || 'dark';
   });
 
+  // Dark mode settings
+  const [darkStyle, setDarkStyleState] = useState<ThemeStyle>(() => {
+    return (localStorage.getItem('dark-style') as ThemeStyle) || 'classic';
+  });
+  const [darkBackground, setDarkBackgroundState] = useState<DarkBackground>(() => {
+    return (localStorage.getItem('dark-background') as DarkBackground) || 'neutral';
+  });
+  const [darkAccent, setDarkAccentState] = useState<ThemeAccent>(() => {
+    return (localStorage.getItem('dark-accent') as ThemeAccent) || 'green';
+  });
+
+  // Light mode settings
+  const [lightStyle, setLightStyleState] = useState<ThemeStyle>(() => {
+    return (localStorage.getItem('light-style') as ThemeStyle) || 'classic';
+  });
+  const [lightBackground, setLightBackgroundState] = useState<LightBackground>(() => {
+    return (localStorage.getItem('light-background') as LightBackground) || 'neutral';
+  });
+  const [lightAccent, setLightAccentState] = useState<ThemeAccent>(() => {
+    return (localStorage.getItem('light-accent') as ThemeAccent) || 'green';
+  });
+
+  // Sync from API on mount
+  useEffect(() => {
+    api.getSettings().then((settings) => {
+      // Dark settings
+      if (settings.dark_style) {
+        setDarkStyleState(settings.dark_style as ThemeStyle);
+        localStorage.setItem('dark-style', settings.dark_style);
+      }
+      if (settings.dark_background) {
+        setDarkBackgroundState(settings.dark_background as DarkBackground);
+        localStorage.setItem('dark-background', settings.dark_background);
+      }
+      if (settings.dark_accent) {
+        setDarkAccentState(settings.dark_accent as ThemeAccent);
+        localStorage.setItem('dark-accent', settings.dark_accent);
+      }
+      // Light settings
+      if (settings.light_style) {
+        setLightStyleState(settings.light_style as ThemeStyle);
+        localStorage.setItem('light-style', settings.light_style);
+      }
+      if (settings.light_background) {
+        setLightBackgroundState(settings.light_background as LightBackground);
+        localStorage.setItem('light-background', settings.light_background);
+      }
+      if (settings.light_accent) {
+        setLightAccentState(settings.light_accent as ThemeAccent);
+        localStorage.setItem('light-accent', settings.light_accent);
+      }
+    }).catch(() => {});
+  }, []);
+
+  // Apply theme classes based on current mode
   useEffect(() => {
     const root = document.documentElement;
-    if (theme === 'dark') {
+
+    // Remove all theme classes
+    root.classList.remove(
+      'dark',
+      'style-classic', 'style-glow', 'style-vibrant',
+      'bg-neutral', 'bg-warm', 'bg-cool', 'bg-oled', 'bg-slate', 'bg-forest',
+      'accent-green', 'accent-teal', 'accent-blue', 'accent-orange', 'accent-purple', 'accent-red'
+    );
+
+    // Apply based on current mode
+    if (mode === 'dark') {
       root.classList.add('dark');
+      root.classList.add(`style-${darkStyle}`);
+      root.classList.add(`bg-${darkBackground}`);
+      root.classList.add(`accent-${darkAccent}`);
     } else {
-      root.classList.remove('dark');
+      root.classList.add(`style-${lightStyle}`);
+      root.classList.add(`bg-${lightBackground}`);
+      root.classList.add(`accent-${lightAccent}`);
     }
-    localStorage.setItem('theme', theme);
-  }, [theme]);
 
-  const toggleTheme = () => {
-    setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
+    localStorage.setItem('theme-mode', mode);
+    localStorage.removeItem('theme');
+  }, [mode, darkStyle, darkBackground, darkAccent, lightStyle, lightBackground, lightAccent]);
+
+  const toggleMode = () => setModeState(prev => prev === 'dark' ? 'light' : 'dark');
+  const setMode = (m: ThemeMode) => setModeState(m);
+
+  // Dark setters
+  const setDarkStyle = (v: ThemeStyle) => {
+    setDarkStyleState(v);
+    localStorage.setItem('dark-style', v);
+    api.updateSettings({ dark_style: v }).catch(() => {});
+  };
+  const setDarkBackground = (v: DarkBackground) => {
+    setDarkBackgroundState(v);
+    localStorage.setItem('dark-background', v);
+    api.updateSettings({ dark_background: v }).catch(() => {});
+  };
+  const setDarkAccent = (v: ThemeAccent) => {
+    setDarkAccentState(v);
+    localStorage.setItem('dark-accent', v);
+    api.updateSettings({ dark_accent: v }).catch(() => {});
   };
 
-  const setTheme = (newTheme: Theme) => {
-    setThemeState(newTheme);
+  // Light setters
+  const setLightStyle = (v: ThemeStyle) => {
+    setLightStyleState(v);
+    localStorage.setItem('light-style', v);
+    api.updateSettings({ light_style: v }).catch(() => {});
+  };
+  const setLightBackground = (v: LightBackground) => {
+    setLightBackgroundState(v);
+    localStorage.setItem('light-background', v);
+    api.updateSettings({ light_background: v }).catch(() => {});
+  };
+  const setLightAccent = (v: ThemeAccent) => {
+    setLightAccentState(v);
+    localStorage.setItem('light-accent', v);
+    api.updateSettings({ light_accent: v }).catch(() => {});
   };
 
   return (
-    <ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
+    <ThemeContext.Provider value={{
+      mode,
+      darkStyle, darkBackground, darkAccent,
+      lightStyle, lightBackground, lightAccent,
+      toggleMode, setMode,
+      setDarkStyle, setDarkBackground, setDarkAccent,
+      setLightStyle, setLightBackground, setLightAccent,
+    }}>
       {children}
     </ThemeContext.Provider>
   );
@@ -45,8 +173,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
 
 export function useTheme() {
   const context = useContext(ThemeContext);
-  if (!context) {
-    throw new Error('useTheme must be used within a ThemeProvider');
-  }
+  if (!context) throw new Error('useTheme must be used within ThemeProvider');
   return context;
 }
+
+export type { ThemeMode, ThemeStyle, DarkBackground, LightBackground, ThemeAccent };

+ 190 - 6
frontend/src/index.css

@@ -5,10 +5,10 @@
 @custom-variant dark (&:where(.dark, .dark *));
 
 @theme {
-  /* Bambu Lab brand colors - always the same */
-  --color-bambu-green: #00ae42;
-  --color-bambu-green-light: #00c64d;
-  --color-bambu-green-dark: #009438;
+  /* Accent colors - use CSS variables for theming */
+  --color-bambu-green: var(--accent);
+  --color-bambu-green-light: var(--accent-light);
+  --color-bambu-green-dark: var(--accent-dark);
 
   /* Theme-aware colors via CSS variables */
   --color-bambu-dark: var(--bg-primary);
@@ -19,8 +19,17 @@
   --color-bambu-gray-dark: var(--text-tertiary);
 }
 
-/* Light mode (default) */
+/* ============================================
+   BASE DEFAULTS
+   ============================================ */
+
 :root {
+  /* Default accent color (green) */
+  --accent: #00ae42;
+  --accent-light: #00c64d;
+  --accent-dark: #009438;
+
+  /* Default light mode background (neutral) */
   --bg-primary: #f5f5f5;
   --bg-secondary: #ffffff;
   --bg-tertiary: #e5e5e5;
@@ -30,6 +39,10 @@
   --text-tertiary: #808080;
   --border-color: #d4d4d4;
 
+  /* Default style (classic) */
+  --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  --glow-color: transparent;
+
   font-family: 'Inter', system-ui, sans-serif;
   line-height: 1.5;
   font-weight: 400;
@@ -39,7 +52,7 @@
   -moz-osx-font-smoothing: grayscale;
 }
 
-/* Dark mode */
+/* Dark mode base */
 .dark {
   --bg-primary: #1a1a1a;
   --bg-secondary: #2d2d2d;
@@ -49,6 +62,172 @@
   --text-muted: #808080;
   --text-tertiary: #4a4a4a;
   --border-color: #3d3d3d;
+  --card-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+}
+
+/* ============================================
+   LAYER 1: BACKGROUND PALETTES
+   ============================================ */
+
+/* Light mode backgrounds */
+.bg-neutral {
+  /* Default - already set in :root */
+}
+
+.bg-warm {
+  --bg-primary: #faf8f5;
+  --bg-secondary: #fffefa;
+  --bg-tertiary: #e8e4dd;
+  --text-primary: #2d2a26;
+  --text-secondary: #5c5750;
+  --text-muted: #7a756c;
+  --text-tertiary: #9a9590;
+  --border-color: #d8d4cc;
+}
+
+.bg-cool {
+  --bg-primary: #f0f4f8;
+  --bg-secondary: #ffffff;
+  --bg-tertiary: #dce4ec;
+  --text-primary: #1a2530;
+  --text-secondary: #4a5568;
+  --text-muted: #6b7a8a;
+  --text-tertiary: #8a9aaa;
+  --border-color: #c8d4e0;
+}
+
+/* Dark mode backgrounds */
+.dark.bg-neutral {
+  --bg-primary: #1a1a1a;
+  --bg-secondary: #2d2d2d;
+  --bg-tertiary: #3d3d3d;
+  --text-primary: #ffffff;
+  --text-secondary: #a0a0a0;
+  --text-muted: #808080;
+  --text-tertiary: #4a4a4a;
+  --border-color: #3d3d3d;
+}
+
+.dark.bg-warm {
+  --bg-primary: #1c1a18;
+  --bg-secondary: #2e2a26;
+  --bg-tertiary: #3e3a36;
+  --text-primary: #f5f0ea;
+  --text-secondary: #b0a898;
+  --text-muted: #8a8278;
+  --text-tertiary: #5a5248;
+  --border-color: #3e3a36;
+}
+
+.dark.bg-cool {
+  --bg-primary: #181c20;
+  --bg-secondary: #262c32;
+  --bg-tertiary: #363e46;
+  --text-primary: #f0f4f8;
+  --text-secondary: #98a8b8;
+  --text-muted: #788898;
+  --text-tertiary: #4a5a6a;
+  --border-color: #363e46;
+}
+
+.dark.bg-oled {
+  --bg-primary: #000000;
+  --bg-secondary: #141414;
+  --bg-tertiary: #1f1f1f;
+  --text-primary: #ffffff;
+  --text-secondary: #a0a0a0;
+  --text-muted: #707070;
+  --text-tertiary: #404040;
+  --border-color: #2a2a2a;
+}
+
+.dark.bg-slate {
+  --bg-primary: #0f172a;
+  --bg-secondary: #1e293b;
+  --bg-tertiary: #334155;
+  --text-primary: #f1f5f9;
+  --text-secondary: #94a3b8;
+  --text-muted: #64748b;
+  --text-tertiary: #475569;
+  --border-color: #334155;
+}
+
+.dark.bg-forest {
+  --bg-primary: #121a16;
+  --bg-secondary: #1c2a22;
+  --bg-tertiary: #2a3d30;
+  --text-primary: #e8f5ec;
+  --text-secondary: #8aa894;
+  --text-muted: #6a8874;
+  --text-tertiary: #4a6854;
+  --border-color: #2a3d30;
+}
+
+/* ============================================
+   LAYER 2: STYLE EFFECTS
+   ============================================ */
+
+/* Classic - default, clean minimal shadows */
+.style-classic {
+  /* Uses default shadows from :root and .dark */
+}
+
+/* Glow - accent-colored glow effects on cards */
+.style-glow {
+  --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 25px color-mix(in srgb, var(--accent) 12%, transparent);
+}
+
+.dark.style-glow {
+  --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 40px color-mix(in srgb, var(--accent) 15%, transparent);
+}
+
+/* Vibrant - dramatic deep shadows, more contrast */
+.style-vibrant {
+  --card-shadow: 0 8px 30px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.dark.style-vibrant {
+  --card-shadow: 0 10px 40px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4);
+}
+
+/* ============================================
+   LAYER 3: ACCENT COLORS
+   ============================================ */
+
+.accent-green {
+  --accent: #00ae42;
+  --accent-light: #00c64d;
+  --accent-dark: #009438;
+}
+
+.accent-teal {
+  --accent: #14b8a6;
+  --accent-light: #2dd4bf;
+  --accent-dark: #0d9488;
+}
+
+.accent-blue {
+  --accent: #3b82f6;
+  --accent-light: #60a5fa;
+  --accent-dark: #2563eb;
+}
+
+.accent-orange {
+  --accent: #f97316;
+  --accent-light: #fb923c;
+  --accent-dark: #ea580c;
+}
+
+.accent-purple {
+  --accent: #8b5cf6;
+  --accent-light: #a78bfa;
+  --accent-dark: #7c3aed;
+}
+
+.accent-red {
+  --accent: #ef4444;
+  --accent-light: #f87171;
+  --accent-dark: #dc2626;
 }
 
 body {
@@ -181,3 +360,8 @@ body {
 .animate-slide-in-left {
   animation: slide-in-left 0.3s ease-out;
 }
+
+/* Card shadows - uses theme-specific shadow */
+.card-shadow {
+  box-shadow: var(--card-shadow);
+}

+ 7 - 6
frontend/src/pages/ArchivesPage.tsx

@@ -42,6 +42,7 @@ import {
   FolderKanban,
 } from 'lucide-react';
 import { api } from '../api/client';
+import { openInSlicer } from '../utils/slicer';
 import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
@@ -233,7 +234,7 @@ function ArchiveCard({
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+          openInSlicer(downloadUrl);
         },
       },
     ] : [
@@ -243,7 +244,7 @@ function ArchiveCard({
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+          openInSlicer(downloadUrl);
         },
       },
     ]),
@@ -498,7 +499,7 @@ function ArchiveCard({
               // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
               const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
               const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
-              window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+              openInSlicer(downloadUrl);
             }}
             title="Open source 3MF in Bambu Studio (right-click for more options)"
           >
@@ -684,7 +685,7 @@ function ArchiveCard({
                 onClick={() => {
                   const filename = archive.print_name || archive.filename || 'model';
                   const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                  window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+                  openInSlicer(downloadUrl);
                 }}
                 title="Open in Bambu Studio"
               >
@@ -700,7 +701,7 @@ function ArchiveCard({
               onClick={() => {
                 const filename = archive.print_name || archive.filename || 'model';
                 const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+                openInSlicer(downloadUrl);
               }}
               title="Open in Bambu Studio to slice"
             >
@@ -1850,7 +1851,7 @@ export function ArchivesPage() {
                     onClick={() => {
                       const filename = archive.print_name || archive.filename || 'model';
                       const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                      window.location.href = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+                      openInSlicer(downloadUrl);
                     }}
                     title="Slice"
                   >

+ 59 - 0
frontend/src/pages/CameraPage.tsx

@@ -100,6 +100,43 @@ export function CameraPage() {
     return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
   }, []);
 
+  // Save window size and position when user resizes or moves (only for popup windows)
+  useEffect(() => {
+    if (!window.opener) return;
+
+    let saveTimeout: NodeJS.Timeout;
+    const saveWindowState = () => {
+      // Debounce to avoid saving during drag
+      clearTimeout(saveTimeout);
+      saveTimeout = setTimeout(() => {
+        localStorage.setItem('cameraWindowState', JSON.stringify({
+          width: window.outerWidth,
+          height: window.outerHeight,
+          left: window.screenX,
+          top: window.screenY,
+        }));
+      }, 500);
+    };
+
+    window.addEventListener('resize', saveWindowState);
+    // Use interval to detect position changes (no native 'move' event)
+    const positionInterval = setInterval(() => {
+      const saved = localStorage.getItem('cameraWindowState');
+      if (saved) {
+        const state = JSON.parse(saved);
+        if (state.left !== window.screenX || state.top !== window.screenY) {
+          saveWindowState();
+        }
+      }
+    }, 1000);
+
+    return () => {
+      clearTimeout(saveTimeout);
+      clearInterval(positionInterval);
+      window.removeEventListener('resize', saveWindowState);
+    };
+  }, []);
+
   // Clean up reconnect timers on unmount
   useEffect(() => {
     return () => {
@@ -178,6 +215,28 @@ export function CameraPage() {
     if (countdownIntervalRef.current) {
       clearInterval(countdownIntervalRef.current);
     }
+
+    // Auto-resize popup window to fit video content (only if no saved preference)
+    if (window.opener && imgRef.current && !localStorage.getItem('cameraWindowState')) {
+      const img = imgRef.current;
+      const videoWidth = img.naturalWidth;
+      const videoHeight = img.naturalHeight;
+
+      if (videoWidth > 0 && videoHeight > 0) {
+        // Add space for header bar (~45px) and some padding
+        const headerHeight = 45;
+        const padding = 16;
+
+        // Calculate window size (outer size includes chrome)
+        const chromeWidth = window.outerWidth - window.innerWidth;
+        const chromeHeight = window.outerHeight - window.innerHeight;
+
+        const targetWidth = videoWidth + padding + chromeWidth;
+        const targetHeight = videoHeight + headerHeight + padding + chromeHeight;
+
+        window.resizeTo(targetWidth, targetHeight);
+      }
+    }
   };
 
   const stopStream = () => {

+ 2 - 2
frontend/src/pages/ExternalLinkPage.tsx

@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
 
 export function ExternalLinkPage() {
   const { id } = useParams<{ id: string }>();
-  const { theme } = useTheme();
+  const { mode } = useTheme();
 
   const { data: link, isLoading, error } = useQuery({
     queryKey: ['external-link', id],
@@ -35,7 +35,7 @@ export function ExternalLinkPage() {
     <iframe
       src={link.url}
       className="h-full w-full border-0"
-      style={{ colorScheme: theme }}
+      style={{ colorScheme: mode }}
       title={link.name}
       sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
     />

+ 13 - 3
frontend/src/pages/MaintenancePage.tsx

@@ -76,12 +76,22 @@ function formatDuration(value: number, type: 'hours' | 'days'): string {
     if (value < 1) return 'Today';
     if (value === 1) return '1 day';
     if (value < 7) return `${Math.round(value)} days`;
-    if (value < 30) return `${Math.round(value / 7)} weeks`;
+    // Show weeks for anything under 6 months for better precision
+    if (value < 180) return `${Math.round(value / 7)} weeks`;
+    // 6+ months show as months
     return `${Math.round(value / 30)} months`;
   } else {
+    // Print hours - convert to readable units
     if (value < 1) return `${Math.round(value * 60)}m`;
-    if (value < 10) return `${value.toFixed(1)}h`;
-    return `${Math.round(value)}h`;
+    if (value < 24) return `${value < 10 ? value.toFixed(1) : Math.round(value)}h`;
+    // 24+ hours: show as days of print time
+    const days = value / 24;
+    if (days < 7) return `${days < 2 ? days.toFixed(1) : Math.round(days)}d`;
+    // 7+ days: show as weeks of print time
+    const weeks = days / 7;
+    if (weeks < 12) return `${weeks < 2 ? weeks.toFixed(1) : Math.round(weeks)}w`;
+    // 12+ weeks: show as months of print time
+    return `${Math.round(weeks / 4)}mo`;
   }
 }
 

+ 527 - 217
frontend/src/pages/PrintersPage.tsx

@@ -40,12 +40,154 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
+import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
+
+// Bambu Lab color code mapping (color suffix from tray_id_name -> color name)
+// tray_id_name format: "A00-Y2" where Y2 is the color code
+const BAMBU_COLOR_CODES: Record<string, string> = {
+  // Yellows
+  'Y0': 'Yellow',
+  'Y1': 'Savana Yellow',
+  'Y2': 'Sunflower Yellow',
+  'Y3': 'Lemon Yellow',
+  // Oranges
+  'O0': 'Orange',
+  'O1': 'Mandarin Orange',
+  'O2': 'Coral Orange',
+  // Reds
+  'R0': 'Red',
+  'R1': 'Scarlet Red',
+  'R2': 'Magenta',
+  'R3': 'Sakura Pink',
+  'R4': 'Raspberry Red',
+  // Pinks
+  'P0': 'Pink',
+  'P1': 'Sakura Pink',
+  // Purples
+  'V0': 'Purple',
+  'V1': 'Violet',
+  'V2': 'Lilac Purple',
+  // Blues
+  'B0': 'Blue',
+  'B1': 'Sky Blue',
+  'B2': 'Navy Blue',
+  'B3': 'Ice Blue',
+  'B4': 'Cyan',
+  // Greens
+  'G0': 'Green',
+  'G1': 'Grass Green',
+  'G2': 'Lime Green',
+  'G3': 'Mint Green',
+  'G4': 'Olive Green',
+  'G5': 'Jungle Green',
+  'G6': 'Bambu Green',
+  // Browns
+  'N0': 'Brown',
+  'N1': 'Peanut Brown',
+  'N2': 'Coffee Brown',
+  'N3': 'Caramel Brown',
+  // Grays
+  'A0': 'Gray',
+  'A1': 'Charcoal Gray',
+  'A2': 'Silver Gray',
+  'A3': 'Titan Gray',
+  // Blacks
+  'K0': 'Black',
+  'K1': 'Black',
+  // Whites
+  'W0': 'White',
+  'W1': 'Jade White',
+  'W2': 'Ivory White',
+  // Special
+  'T0': 'Transparent',
+  'C0': 'Marble',
+  'X0': 'Bronze',
+  'X1': 'Gold',
+  'X2': 'Silver',
+};
+
+// Get color name from Bambu Lab tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow")
+function getBambuColorName(trayIdName: string | null | undefined): string | null {
+  if (!trayIdName) return null;
+  // Extract color code after the dash (e.g., "A00-Y2" -> "Y2")
+  const parts = trayIdName.split('-');
+  if (parts.length < 2) return null;
+  const colorCode = parts[1];
+  return BAMBU_COLOR_CODES[colorCode] || null;
+}
+
+// Convert hex color to basic color name
+function hexToBasicColorName(hex: string | null | undefined): string {
+  if (!hex || hex.length < 6) return 'Unknown';
+
+  // Parse RGB from hex (format: RRGGBBAA or RRGGBB)
+  const r = parseInt(hex.substring(0, 2), 16);
+  const g = parseInt(hex.substring(2, 4), 16);
+  const b = parseInt(hex.substring(4, 6), 16);
+
+  // Calculate HSL for better color classification
+  const max = Math.max(r, g, b) / 255;
+  const min = Math.min(r, g, b) / 255;
+  const l = (max + min) / 2;
+
+  let h = 0;
+  let s = 0;
+
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+    const rNorm = r / 255;
+    const gNorm = g / 255;
+    const bNorm = b / 255;
+
+    if (max === rNorm) {
+      h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
+    } else if (max === gNorm) {
+      h = ((bNorm - rNorm) / d + 2) / 6;
+    } else {
+      h = ((rNorm - gNorm) / d + 4) / 6;
+    }
+  }
+
+  // Convert to degrees
+  h = h * 360;
+
+  // Classify by lightness first
+  if (l < 0.15) return 'Black';
+  if (l > 0.85) return 'White';
+
+  // Low saturation = gray
+  if (s < 0.15) {
+    if (l < 0.4) return 'Dark Gray';
+    if (l > 0.6) return 'Light Gray';
+    return 'Gray';
+  }
+
+  // Classify by hue
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 45) return 'Orange';
+  if (h < 70) return 'Yellow';
+  if (h < 150) return 'Green';
+  if (h < 200) return 'Cyan';
+  if (h < 260) return 'Blue';
+  if (h < 290) return 'Purple';
+  if (h < 345) return 'Pink';
+
+  return 'Unknown';
+}
+
+// Format K value with 3 decimal places, default to 0.020 if null
+function formatKValue(k: number | null | undefined): string {
+  const value = k ?? 0.020;
+  return value.toFixed(3);
+}
 
 // Nozzle side indicators (Bambu Lab style - square badge with L/R)
 function NozzleBadge({ side }: { side: 'L' | 'R' }) {
-  const { theme } = useTheme();
-  // Light theme: #e7f5e9 (light green), Dark theme: #1a4d2e (dark green)
-  const bgColor = theme === 'dark' ? '#1a4d2e' : '#e7f5e9';
+  const { mode } = useTheme();
+  // Light mode: #e7f5e9 (light green), Dark mode: #1a4d2e (dark green)
+  const bgColor = mode === 'dark' ? '#1a4d2e' : '#e7f5e9';
   return (
     <span
       className="inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded"
@@ -56,88 +198,6 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
   );
 }
 
-// AMS 4-tray device icon with fillable colored spool slots (Bambu Studio style)
-interface AMS4TrayIconProps {
-  colors: (string | null)[]; // Array of 4 colors (hex) or null for empty
-  className?: string;
-}
-
-function AMS4TrayIcon({ colors, className }: AMS4TrayIconProps) {
-  // Spool positions: x start, centered at 12.5, 21.5, 30.5, 39.5
-  // Each spool slot is 6 units wide (from 9.5-15.5, 18.5-24.5, etc.)
-  const spoolSlots = [
-    { x: 9.5, cx: 12.5 },
-    { x: 18.5, cx: 21.5 },
-    { x: 27.5, cx: 30.5 },
-    { x: 36.5, cx: 39.5 },
-  ];
-
-  return (
-    <svg className={className} width="56" height="34" viewBox="0 0 52 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-      {/* Outer casing with window */}
-      <path
-        fillRule="evenodd"
-        clipRule="evenodd"
-        d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z"
-        fill="#2F2E33"
-      />
-      {/* Spool color fills - rectangles that fill the visible window area */}
-      {spoolSlots.map((slot, i) => (
-        colors[i] ? (
-          <rect key={i} x={slot.x} y="8" width="6" height="16" fill={colors[i]!} />
-        ) : (
-          <g key={i}>
-            <rect x={slot.x} y="8" width="6" height="16" fill="#ffffff" />
-            <line x1={slot.x} y1="8" x2={slot.x + 6} y2="24" stroke="#555555" strokeWidth="1.5" />
-          </g>
-        )
-      ))}
-      {/* Bottom half overlay (spool holders - creates rounded bottom edges) */}
-      <path
-        fillRule="evenodd"
-        clipRule="evenodd"
-        d="M36.5 16H33.5V18.2617C33.5 19.9186 32.1569 21.2617 30.5 21.2617C28.8431 21.2617 27.5 19.9186 27.5 18.2617V16H24.5V18.2617C24.5 19.9186 23.1569 21.2617 21.5 21.2617C19.8431 21.2617 18.5 19.9186 18.5 18.2617V16H15.5V18.2617C15.5 19.9186 14.1569 21.2617 12.5 21.2617C10.8432 21.2617 9.5 19.9186 9.5 18.2617V16H4V28H48V16H42.5V18.2617C42.5 19.9186 41.1569 21.2617 39.5 21.2617C37.8431 21.2617 36.5 19.9186 36.5 18.2617V16Z"
-        fill="#767676"
-      />
-      {/* Top half overlay (spool tops - creates rounded top edges) */}
-      <path
-        fillRule="evenodd"
-        clipRule="evenodd"
-        d="M6 9.18382C6 6.32088 8.32088 4 11.1838 4H40.8162C43.6791 4 46 6.32088 46 9.18382V16H42.5V12.2617C42.5 10.6049 41.1569 9.26172 39.5 9.26172C37.8431 9.26172 36.5 10.6049 36.5 12.2617V16H33.5V12.2617C33.5 10.6049 32.1569 9.26172 30.5 9.26172C28.8431 9.26172 27.5 10.6049 27.5 12.2617V16H24.5V12.2617C24.5 10.6049 23.1569 9.26172 21.5 9.26172C19.8431 9.26172 18.5 10.6049 18.5 12.2617V16H15.5V12.2617C15.5 10.6049 14.1569 9.26172 12.5 9.26172C10.8432 9.26172 9.5 10.6049 9.5 12.2617V16H6V9.18382Z"
-        fill="#BFBFBF"
-      />
-    </svg>
-  );
-}
-
-// AMS 1-tray device icon (AMS-HT) with fillable colored slot (Bambu Studio style)
-interface AMS1TrayIconProps {
-  color: string | null; // Hex color or null for empty
-  className?: string;
-}
-
-function AMS1TrayIcon({ color, className }: AMS1TrayIconProps) {
-  return (
-    <svg className={className} width="56" height="56" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
-      {/* Filament color fill */}
-      {color ? (
-        <rect x="8.3" y="5.2" width="3.8" height="5.1" fill={color} rx="0.3"/>
-      ) : (
-        <g>
-          <rect x="8.3" y="5.2" width="3.8" height="5.1" fill="#ffffff" rx="0.3"/>
-          <line x1="8.3" y1="5.2" x2="12.1" y2="10.3" stroke="#555555" strokeWidth="0.8" />
-        </g>
-      )}
-      {/* Device outline - top housing */}
-      <path d="M5.88312 4.68555C5.88312 4.13326 6.33083 3.68555 6.88312 3.68555H13.5059C14.0582 3.68555 14.5059 4.13326 14.5059 4.68555V10.3887H5.88312V4.68555Z" stroke="#6B6B6B"/>
-      {/* Bottom base */}
-      <rect x="3.8725" y="10.3887" width="12.7037" height="7.55371" rx="1.2" stroke="#6B6B6B"/>
-      {/* Inner tray outline */}
-      <path d="M8.21991 5.65234C8.21991 5.3762 8.44377 5.15234 8.71991 5.15234H11.7288C12.005 5.15234 12.2288 5.3762 12.2288 5.65234V10.3887H8.21991V5.65234Z" stroke="#6B6B6B"/>
-    </svg>
-  );
-}
-
 // Water drop SVG - empty outline (Bambu Lab style from bambu-humidity)
 function WaterDropEmpty({ className }: { className?: string }) {
   return (
@@ -247,9 +307,10 @@ interface HumidityIndicatorProps {
   goodThreshold?: number;  // <= this is green
   fairThreshold?: number;  // <= this is orange, > is red
   onClick?: () => void;
+  compact?: boolean;  // Smaller version for grid layout
 }
 
-function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick }: HumidityIndicatorProps) {
+function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick, compact }: HumidityIndicatorProps) {
   const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;
   const good = typeof goodThreshold === 'number' ? goodThreshold : 40;
   const fair = typeof fairThreshold === 'number' ? fairThreshold : 60;
@@ -289,11 +350,11 @@ function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, o
     <button
       type="button"
       onClick={onClick}
-      className={`flex items-center justify-end gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
+      className={`flex items-center gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
       title={`Humidity: ${humidityValue}% - ${statusText}${onClick ? ' (click for history)' : ''}`}
     >
-      <DropComponent className="w-3 h-4" />
-      <span className="text-xs font-medium tabular-nums w-8 text-right" style={{ color: textColor }}>{humidityValue}%</span>
+      <DropComponent className={compact ? "w-2.5 h-3" : "w-3 h-4"} />
+      <span className={`font-medium tabular-nums ${compact ? 'text-[10px]' : 'text-xs'}`} style={{ color: textColor }}>{humidityValue}%</span>
     </button>
   );
 }
@@ -304,9 +365,10 @@ interface TemperatureIndicatorProps {
   goodThreshold?: number;  // <= this is blue
   fairThreshold?: number;  // <= this is orange, > is red
   onClick?: () => void;
+  compact?: boolean;  // Smaller version for grid layout
 }
 
-function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick }: TemperatureIndicatorProps) {
+function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick, compact }: TemperatureIndicatorProps) {
   // Ensure thresholds are numbers
   const good = typeof goodThreshold === 'number' ? goodThreshold : 28;
   const fair = typeof fairThreshold === 'number' ? fairThreshold : 35;
@@ -336,8 +398,8 @@ function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, on
       className={`flex items-center gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
       title={`Temperature: ${temp}°C - ${statusText}${onClick ? ' (click for history)' : ''}`}
     >
-      <ThermoComponent className="w-3 h-4" />
-      <span className="tabular-nums w-12 text-right" style={{ color: textColor }}>{temp}°C</span>
+      <ThermoComponent className={compact ? "w-2.5 h-3" : "w-3 h-4"} />
+      <span className={`tabular-nums text-right ${compact ? 'text-[10px] w-8' : 'w-12'}`} style={{ color: textColor }}>{temp}°C</span>
     </button>
   );
 }
@@ -356,6 +418,13 @@ function getAmsLabel(amsId: number | string, trayCount: number): string {
   return isHt ? `HT-${letter}` : `AMS-${letter}`;
 }
 
+// Get fill bar color based on spool fill level
+function getFillBarColor(fillLevel: number): string {
+  if (fillLevel > 50) return '#00ae42'; // Green - good
+  if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
+  return '#ef4444'; // Red - critical (< 15%)
+}
+
 function formatTime(seconds: number): string {
   const hours = Math.floor(seconds / 3600);
   const minutes = Math.floor((seconds % 3600) / 60);
@@ -615,6 +684,32 @@ function PrinterCard({
     refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
   });
 
+  // Collect unique tray_info_idx values for cloud filament info lookup
+  const trayInfoIds = useMemo(() => {
+    const ids = new Set<string>();
+    if (status?.ams) {
+      for (const ams of status.ams) {
+        for (const tray of ams.tray || []) {
+          if (tray.tray_info_idx) {
+            ids.add(tray.tray_info_idx);
+          }
+        }
+      }
+    }
+    if (status?.vt_tray?.tray_info_idx) {
+      ids.add(status.vt_tray.tray_info_idx);
+    }
+    return Array.from(ids);
+  }, [status?.ams, status?.vt_tray]);
+
+  // Fetch cloud filament info for tooltips (name includes color, also has K value)
+  const { data: filamentInfo } = useQuery({
+    queryKey: ['filamentInfo', trayInfoIds],
+    queryFn: () => api.getFilamentInfo(trayInfoIds),
+    enabled: trayInfoIds.length > 0,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+  });
+
   // Cache WiFi signal to prevent it disappearing on updates
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   useEffect(() => {
@@ -653,6 +748,19 @@ function PrinterCard({
   }, [status?.ams]);
   const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;
 
+  // Cache tray_now to prevent flickering when 255 (unloaded) or undefined values come in
+  // Only update cache when we get a valid tray ID (0-253 or 254 for external)
+  const cachedTrayNow = useRef<number>(255);
+  const currentTrayNow = status?.tray_now;
+  // Update cache synchronously during render if we have a valid value
+  if (currentTrayNow !== undefined && currentTrayNow !== 255) {
+    cachedTrayNow.current = currentTrayNow;
+  }
+  // Use cached value if current is 255/undefined but we had a valid value before
+  const effectiveTrayNow = (currentTrayNow === undefined || currentTrayNow === 255)
+    ? cachedTrayNow.current
+    : currentTrayNow;
+
   // Fetch smart plug for this printer
   const { data: smartPlug } = useQuery({
     queryKey: ['smartPlugByPrinter', printer.id],
@@ -1135,132 +1243,328 @@ function PrinterCard({
               );
             })()}
 
-            {/* AMS Units with Device Icons, Humidity & Temperature */}
-            {amsData && amsData.length > 0 && viewMode === 'expanded' && (
-              <div className="mt-3 space-y-2">
-                {amsData.map((ams) => {
-                  // For dual nozzle printers, determine which nozzle this AMS is connected to
-                  // Use actual ams.id for map lookup (map uses real IDs: 0-3 for AMS, 128+ for AMS-HT)
-                  const mappedExtruderId = amsExtruderMap[String(ams.id)];
-                  // Fallback: normalize ID for conventional mapping (0=R, 1=L)
-                  const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;
-                  const extruderId = mappedExtruderId !== undefined
-                    ? mappedExtruderId
-                    : normalizedId; // Fallback: AMS 0 → extruder 0 (R), AMS 1 → extruder 1 (L)
-                  // Use printer.nozzle_count as primary source (stable), fallback to nozzle_2 temp
-                  const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
-                  // extruder 0 = Right, extruder 1 = Left
-                  const isLeftNozzle = extruderId === 1;
-                  const isRightNozzle = extruderId === 0;
-
-                  // Get colors for the AMS icon (null for empty slots)
-                  const slotColors = ams.tray.map(tray =>
-                    tray.tray_color ? `#${tray.tray_color}` : (tray.tray_type ? '#333' : null)
-                  );
-                  const isHtAms = ams.tray.length === 1;
-
-                  return (
-                    <div key={ams.id} className="p-2 bg-bambu-dark rounded-lg">
-                      <div className="flex flex-wrap items-center gap-2 sm:gap-3">
-                        {/* Nozzle badge + AMS device icon */}
-                        <div className="flex items-center gap-1 flex-shrink-0">
-                          {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
-                            <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
-                          )}
-                          {isHtAms ? (
-                            <AMS1TrayIcon
-                              color={slotColors[0]}
-                              className="flex-shrink-0"
-                            />
-                          ) : (
-                            <AMS4TrayIcon
-                              colors={slotColors as (string | null)[]}
-                              className="flex-shrink-0"
-                            />
-                          )}
-                        </div>
-
-                        {/* Label and filament info */}
-                        <div className="flex-1 min-w-0">
-                          <span className="text-xs text-bambu-gray font-medium">
-                            {getAmsLabel(ams.id, ams.tray.length)}
-                          </span>
-                          {/* Filament types and fill levels */}
-                          <div className="mt-0.5 text-[10px] flex items-start">
-                            {ams.tray.map((tray, i) => (
-                              <div key={i} className="flex items-start">
-                                <div className="flex flex-col">
-                                  <span className="text-bambu-gray/70 truncate max-w-[60px] sm:max-w-none">
-                                    {tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'}
-                                  </span>
-                                  <span className="text-bambu-gray/50 truncate">
-                                    {tray.tray_type && tray.remain >= 0 ? `${tray.remain}%` : '—'}
-                                  </span>
-                                </div>
-                                {i < ams.tray.length - 1 && (
-                                  <span className="text-bambu-gray/50 mx-1 flex flex-col">
-                                    <span>·</span>
-                                    <span>·</span>
-                                  </span>
+            {/* AMS Units - 2-Column Grid Layout */}
+            {amsData && amsData.length > 0 && viewMode === 'expanded' && (() => {
+              // Separate regular AMS (4-tray) from HT AMS (1-tray)
+              const regularAms = amsData.filter(ams => ams.tray.length > 1);
+              const htAms = amsData.filter(ams => ams.tray.length === 1);
+              const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
+
+              return (
+                <div className="mt-4 pt-3 border-t border-bambu-dark-tertiary/50">
+                  {/* Section Header */}
+                  <div className="flex items-center gap-2 mb-3">
+                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                      Filaments
+                    </span>
+                    <div className="flex-1 h-px bg-bambu-dark-tertiary/30" />
+                  </div>
+
+                  {/* AMS Content */}
+                  <div className="space-y-3">
+                    {/* Row 1-2: Regular AMS (4-tray) in 2-column grid */}
+                    {regularAms.length > 0 && (
+                      <div className="grid grid-cols-2 gap-3">
+                        {regularAms.map((ams) => {
+                        const mappedExtruderId = amsExtruderMap[String(ams.id)];
+                        const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;
+                        const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
+                        const isLeftNozzle = extruderId === 1;
+                        const isRightNozzle = extruderId === 0;
+
+                        return (
+                          <div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
+                            {/* Header: Label + Stats (no icon) */}
+                            <div className="flex items-center justify-between mb-2">
+                              <div className="flex items-center gap-1.5">
+                                <span className="text-[10px] text-white font-medium">
+                                  {getAmsLabel(ams.id, ams.tray.length)}
+                                </span>
+                                {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
+                                  <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                                 )}
                               </div>
-                            ))}
-                          </div>
-                        </div>
-                        {/* Humidity/temp - responsive positioning */}
-                        {(ams.humidity != null || ams.temp != null) && (
-                          <div className="flex items-center gap-2 text-xs flex-shrink-0 ml-auto">
-                            {ams.humidity != null && (
-                              <HumidityIndicator
-                                humidity={ams.humidity}
-                                goodThreshold={amsThresholds?.humidityGood}
-                                fairThreshold={amsThresholds?.humidityFair}
-                                onClick={() => setAmsHistoryModal({
-                                  amsId: ams.id,
-                                  amsLabel: getAmsLabel(ams.id, ams.tray.length),
-                                  mode: 'humidity',
-                                })}
-                              />
-                            )}
-                            {ams.temp != null && (
-                              <TemperatureIndicator
-                                temp={ams.temp}
-                                goodThreshold={amsThresholds?.tempGood}
-                                fairThreshold={amsThresholds?.tempFair}
-                                onClick={() => setAmsHistoryModal({
-                                  amsId: ams.id,
-                                  amsLabel: getAmsLabel(ams.id, ams.tray.length),
-                                  mode: 'temperature',
-                                })}
-                              />
-                            )}
+                              {(ams.humidity != null || ams.temp != null) && (
+                                <div className="flex items-center gap-1.5">
+                                  {ams.humidity != null && (
+                                    <HumidityIndicator
+                                      humidity={ams.humidity}
+                                      goodThreshold={amsThresholds?.humidityGood}
+                                      fairThreshold={amsThresholds?.humidityFair}
+                                      onClick={() => setAmsHistoryModal({
+                                        amsId: ams.id,
+                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        mode: 'humidity',
+                                      })}
+                                      compact
+                                    />
+                                  )}
+                                  {ams.temp != null && (
+                                    <TemperatureIndicator
+                                      temp={ams.temp}
+                                      goodThreshold={amsThresholds?.tempGood}
+                                      fairThreshold={amsThresholds?.tempFair}
+                                      onClick={() => setAmsHistoryModal({
+                                        amsId: ams.id,
+                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        mode: 'temperature',
+                                      })}
+                                      compact
+                                    />
+                                  )}
+                                </div>
+                              )}
+                            </div>
+                            {/* Slots grid: 4 columns - always render 4 slots */}
+                            <div className="grid grid-cols-4 gap-1.5">
+                              {[0, 1, 2, 3].map((slotIdx) => {
+                                // Find tray data for this slot (may be undefined if data incomplete)
+                                // Use array index if available, as tray.id may not always be set
+                                const tray = ams.tray[slotIdx] || ams.tray.find(t => t.id === slotIdx);
+                                const hasFillLevel = tray?.tray_type && tray.remain >= 0;
+                                const isEmpty = !tray?.tray_type;
+                                // Check if this is the currently loaded tray
+                                // Global tray ID = ams.id * 4 + slot index (for standard AMS)
+                                const globalTrayId = ams.id * 4 + slotIdx;
+                                const isActive = effectiveTrayNow === globalTrayId;
+                                // Get cloud preset info if available
+                                const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
+
+                                // Build filament data for hover card
+                                const filamentData = tray?.tray_type ? {
+                                  vendor: (tray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                                  profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                                  colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
+                                  colorHex: tray.tray_color || null,
+                                  kFactor: formatKValue(tray.k),
+                                  fillLevel: hasFillLevel ? tray.remain : null,
+                                } : null;
+
+                                const slotContent = (
+                                  <div
+                                    className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
+                                  >
+                                    <div
+                                      className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
+                                      style={{
+                                        backgroundColor: tray?.tray_color ? `#${tray.tray_color}` : (tray?.tray_type ? '#333' : 'transparent'),
+                                        borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+                                        borderStyle: isEmpty ? 'dashed' : 'solid',
+                                      }}
+                                    />
+                                    <div className="text-[9px] text-white font-bold truncate">
+                                      {tray?.tray_type || '—'}
+                                    </div>
+                                    {/* Fill bar */}
+                                    <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
+                                      {hasFillLevel && tray ? (
+                                        <div
+                                          className="h-full rounded-full transition-all"
+                                          style={{
+                                            width: `${tray.remain}%`,
+                                            backgroundColor: getFillBarColor(tray.remain),
+                                          }}
+                                        />
+                                      ) : tray?.tray_type ? (
+                                        <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                                      ) : null}
+                                    </div>
+                                  </div>
+                                );
+
+                                return filamentData ? (
+                                  <FilamentHoverCard key={slotIdx} data={filamentData}>
+                                    {slotContent}
+                                  </FilamentHoverCard>
+                                ) : (
+                                  <EmptySlotHoverCard key={slotIdx}>
+                                    {slotContent}
+                                  </EmptySlotHoverCard>
+                                );
+                              })}
+                            </div>
                           </div>
-                        )}
-                      </div>
+                        );
+                      })}
                     </div>
-                  );
-                })}
-                {/* External spool indicator */}
-                {status.vt_tray && status.vt_tray.tray_type && (
-                  <div className="p-2 bg-bambu-dark rounded-lg">
-                    <div className="flex items-center gap-3">
-                      <div
-                        className="w-10 h-10 rounded-full border-2 border-white/20 flex-shrink-0"
-                        style={{
-                          backgroundColor: status.vt_tray.tray_color ? `#${status.vt_tray.tray_color}` : '#333',
-                        }}
-                      />
-                      <div>
-                        <span className="text-xs text-bambu-gray font-medium">External</span>
-                        <p className="text-[10px] text-bambu-gray/70">
-                          {status.vt_tray.tray_sub_brands || status.vt_tray.tray_type || 'Spool'}
-                        </p>
+                  )}
+
+                    {/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */}
+                    {(htAms.length > 0 || (status.vt_tray && status.vt_tray.tray_type)) && (
+                      <div className="grid grid-cols-4 gap-3">
+                      {/* HT AMS units - name/badge top, slot left, stats right */}
+                      {htAms.map((ams) => {
+                        const mappedExtruderId = amsExtruderMap[String(ams.id)];
+                        const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;
+                        const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
+                        const isLeftNozzle = extruderId === 1;
+                        const isRightNozzle = extruderId === 0;
+                        const tray = ams.tray[0];
+                        const hasFillLevel = tray?.tray_type && tray.remain >= 0;
+                        const isEmpty = !tray?.tray_type;
+                        // Check if this is the currently loaded tray
+                        // Global tray ID = ams.id * 4 + tray.id
+                        const globalTrayId = ams.id * 4 + (tray?.id ?? 0);
+                        const isActive = effectiveTrayNow === globalTrayId;
+                        // Get cloud preset info if available
+                        const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
+
+                        // Build filament data for hover card
+                        const filamentData = tray?.tray_type ? {
+                          vendor: (tray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                          profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                          colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
+                          colorHex: tray.tray_color || null,
+                          kFactor: formatKValue(tray.k),
+                          fillLevel: hasFillLevel ? tray.remain : null,
+                        } : null;
+
+                        const slotContent = (
+                          <div
+                            className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
+                          >
+                            <div
+                              className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
+                              style={{
+                                backgroundColor: tray?.tray_color ? `#${tray.tray_color}` : (tray?.tray_type ? '#333' : 'transparent'),
+                                borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+                                borderStyle: isEmpty ? 'dashed' : 'solid',
+                              }}
+                            />
+                            <div className="text-[9px] text-white font-bold truncate">
+                              {tray?.tray_type || '—'}
+                            </div>
+                            {/* Fill bar */}
+                            <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
+                              {hasFillLevel ? (
+                                <div
+                                  className="h-full rounded-full transition-all"
+                                  style={{
+                                    width: `${tray.remain}%`,
+                                    backgroundColor: getFillBarColor(tray.remain),
+                                  }}
+                                />
+                              ) : tray?.tray_type ? (
+                                <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                              ) : null}
+                            </div>
+                          </div>
+                        );
+
+                        return (
+                          <div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
+                            {/* Row 1: Label + Nozzle */}
+                            <div className="flex items-center gap-1 mb-2">
+                              <span className="text-[10px] text-white font-medium">
+                                {getAmsLabel(ams.id, ams.tray.length)}
+                              </span>
+                              {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
+                                <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
+                              )}
+                            </div>
+                            {/* Row 2: Slot (left) + Stats (right stacked) */}
+                            <div className="flex gap-1.5">
+                              {/* Slot - takes remaining width */}
+                              {filamentData ? (
+                                <FilamentHoverCard data={filamentData} className="flex-1">
+                                  {slotContent}
+                                </FilamentHoverCard>
+                              ) : (
+                                <EmptySlotHoverCard className="flex-1">
+                                  {slotContent}
+                                </EmptySlotHoverCard>
+                              )}
+                              {/* Stats stacked vertically: Temp on top, Humidity below */}
+                              {(ams.humidity != null || ams.temp != null) && (
+                                <div className="flex flex-col justify-center gap-1 shrink-0">
+                                  {ams.temp != null && (
+                                    <TemperatureIndicator
+                                      temp={ams.temp}
+                                      goodThreshold={amsThresholds?.tempGood}
+                                      fairThreshold={amsThresholds?.tempFair}
+                                      onClick={() => setAmsHistoryModal({
+                                        amsId: ams.id,
+                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        mode: 'temperature',
+                                      })}
+                                      compact
+                                    />
+                                  )}
+                                  {ams.humidity != null && (
+                                    <HumidityIndicator
+                                      humidity={ams.humidity}
+                                      goodThreshold={amsThresholds?.humidityGood}
+                                      fairThreshold={amsThresholds?.humidityFair}
+                                      onClick={() => setAmsHistoryModal({
+                                        amsId: ams.id,
+                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        mode: 'humidity',
+                                      })}
+                                      compact
+                                    />
+                                  )}
+                                </div>
+                              )}
+                            </div>
+                          </div>
+                        );
+                      })}
+                      {/* External spool - name top, slot below (no stats) */}
+                      {status.vt_tray && status.vt_tray.tray_type && (() => {
+                        const extTray = status.vt_tray;
+                        // Check if external spool is active (tray_now = 254)
+                        const isExtActive = effectiveTrayNow === 254;
+                        // Get cloud preset info if available
+                        const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
+
+                        // Build filament data for hover card
+                        const extFilamentData = {
+                          vendor: (extTray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                          profile: extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                          colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
+                          colorHex: extTray.tray_color || null,
+                          kFactor: formatKValue(extTray.k),
+                          fillLevel: null, // External spool has unknown fill level
+                        };
+
+                        const extSlotContent = (
+                          <div className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
+                            <div
+                              className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
+                              style={{
+                                backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : '#333',
+                                borderColor: isExtActive ? 'var(--accent)' : 'rgba(255,255,255,0.1)',
+                              }}
+                            />
+                            <div className="text-[9px] text-white font-bold truncate">
+                              {extTray.tray_type || 'Spool'}
+                            </div>
+                            {/* Unknown fill level - subtle bar */}
+                            <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
+                              <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                            </div>
+                          </div>
+                        );
+
+                        return (
+                          <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
+                            {/* Row 1: Label */}
+                            <div className="flex items-center gap-1 mb-2">
+                              <span className="text-[10px] text-white font-medium">External</span>
+                            </div>
+                            {/* Row 2: Slot (full width since no stats) */}
+                            <FilamentHoverCard data={extFilamentData}>
+                              {extSlotContent}
+                            </FilamentHoverCard>
+                          </div>
+                        );
+                      })()}
                       </div>
-                    </div>
+                    )}
                   </div>
-                )}
-              </div>
-            )}
+                </div>
+              );
+            })()}
           </>
         )}
 
@@ -1362,11 +1666,17 @@ function PrinterCard({
                 variant="secondary"
                 size="sm"
                 onClick={() => {
-                  window.open(
-                    `/camera/${printer.id}`,
-                    `camera-${printer.id}`,
-                    'width=640,height=400,menubar=no,toolbar=no,location=no,status=no'
-                  );
+                  // Use saved window state or defaults
+                  const saved = localStorage.getItem('cameraWindowState');
+                  const state = saved ? JSON.parse(saved) : { width: 640, height: 400 };
+                  const features = [
+                    `width=${state.width}`,
+                    `height=${state.height}`,
+                    state.left !== undefined ? `left=${state.left}` : '',
+                    state.top !== undefined ? `top=${state.top}` : '',
+                    'menubar=no,toolbar=no,location=no,status=no',
+                  ].filter(Boolean).join(',');
+                  window.open(`/camera/${printer.id}`, `camera-${printer.id}`, features);
                 }}
                 disabled={!status?.connected}
                 title="Open camera in new window"

+ 128 - 3
frontend/src/pages/SettingsPage.tsx

@@ -21,12 +21,21 @@ import { virtualPrinterApi } from '../api/client';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
+import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
 import { useState, useEffect, useRef, useCallback } from 'react';
+import { Palette } from 'lucide-react';
 
 export function SettingsPage() {
   const queryClient = useQueryClient();
   const { t, i18n } = useTranslation();
   const { showToast, showPersistentToast, dismissToast } = useToast();
+  const {
+    mode,
+    darkStyle, darkBackground, darkAccent,
+    lightStyle, lightBackground, lightAccent,
+    setDarkStyle, setDarkBackground, setDarkAccent,
+    setLightStyle, setLightBackground, setLightAccent,
+  } = useTheme();
   const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
   const [showPlugModal, setShowPlugModal] = useState(false);
   const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
@@ -586,6 +595,121 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Palette className="w-5 h-5" />
+                Appearance
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-6">
+              {/* Dark Mode Settings */}
+              <div className={`space-y-3 p-4 rounded-lg border ${mode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
+                <h3 className="text-sm font-medium text-white flex items-center gap-2">
+                  Dark Mode
+                  {mode === 'dark' && <span className="text-xs text-bambu-green">(active)</span>}
+                </h3>
+                <div className="grid grid-cols-3 gap-3">
+                  <div>
+                    <label className="block text-xs text-bambu-gray mb-1">Background</label>
+                    <select
+                      value={darkBackground}
+                      onChange={(e) => { setDarkBackground(e.target.value as DarkBackground); showToast('Settings saved', 'success'); }}
+                      className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    >
+                      <option value="neutral">Neutral</option>
+                      <option value="warm">Warm</option>
+                      <option value="cool">Cool</option>
+                      <option value="oled">OLED Black</option>
+                      <option value="slate">Slate Blue</option>
+                      <option value="forest">Forest Green</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label className="block text-xs text-bambu-gray mb-1">Accent</label>
+                    <select
+                      value={darkAccent}
+                      onChange={(e) => { setDarkAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
+                      className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    >
+                      <option value="green">Green</option>
+                      <option value="teal">Teal</option>
+                      <option value="blue">Blue</option>
+                      <option value="orange">Orange</option>
+                      <option value="purple">Purple</option>
+                      <option value="red">Red</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label className="block text-xs text-bambu-gray mb-1">Style</label>
+                    <select
+                      value={darkStyle}
+                      onChange={(e) => { setDarkStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
+                      className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    >
+                      <option value="classic">Classic</option>
+                      <option value="glow">Glow</option>
+                      <option value="vibrant">Vibrant</option>
+                    </select>
+                  </div>
+                </div>
+              </div>
+
+              {/* Light Mode Settings */}
+              <div className={`space-y-3 p-4 rounded-lg border ${mode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
+                <h3 className="text-sm font-medium text-white flex items-center gap-2">
+                  Light Mode
+                  {mode === 'light' && <span className="text-xs text-bambu-green">(active)</span>}
+                </h3>
+                <div className="grid grid-cols-3 gap-3">
+                  <div>
+                    <label className="block text-xs text-bambu-gray mb-1">Background</label>
+                    <select
+                      value={lightBackground}
+                      onChange={(e) => { setLightBackground(e.target.value as LightBackground); showToast('Settings saved', 'success'); }}
+                      className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    >
+                      <option value="neutral">Neutral</option>
+                      <option value="warm">Warm</option>
+                      <option value="cool">Cool</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label className="block text-xs text-bambu-gray mb-1">Accent</label>
+                    <select
+                      value={lightAccent}
+                      onChange={(e) => { setLightAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
+                      className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    >
+                      <option value="green">Green</option>
+                      <option value="teal">Teal</option>
+                      <option value="blue">Blue</option>
+                      <option value="orange">Orange</option>
+                      <option value="purple">Purple</option>
+                      <option value="red">Red</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label className="block text-xs text-bambu-gray mb-1">Style</label>
+                    <select
+                      value={lightStyle}
+                      onChange={(e) => { setLightStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
+                      className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    >
+                      <option value="classic">Classic</option>
+                      <option value="glow">Glow</option>
+                      <option value="vibrant">Vibrant</option>
+                    </select>
+                  </div>
+                </div>
+              </div>
+
+              <p className="text-xs text-bambu-gray">
+                Toggle between dark and light mode using the sun/moon icon in the sidebar.
+              </p>
+            </CardContent>
+          </Card>
+
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
@@ -658,6 +782,10 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+        </div>
+
+        {/* Second Column - Cost, AMS & Spoolman */}
+        <div className="space-y-6 flex-1 lg:max-w-md">
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
@@ -730,10 +858,7 @@ export function SettingsPage() {
               </div>
             </CardContent>
           </Card>
-        </div>
 
-        {/* Second Column - AMS & Spoolman */}
-        <div className="space-y-6 flex-1 lg:max-w-md">
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>

+ 70 - 0
frontend/src/utils/slicer.ts

@@ -0,0 +1,70 @@
+/**
+ * Utility for opening files in Bambu Studio slicer
+ *
+ * The URL protocol handler is OS-specific:
+ * - Windows: bambustudio://
+ * - macOS/Linux: bambustudioopen://
+ */
+
+type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
+
+/**
+ * Detect the user's operating system
+ */
+export function detectPlatform(): Platform {
+  const userAgent = navigator.userAgent.toLowerCase();
+  const platform = navigator.platform?.toLowerCase() || '';
+
+  if (userAgent.includes('win') || platform.includes('win')) {
+    return 'windows';
+  }
+  if (userAgent.includes('mac') || platform.includes('mac')) {
+    return 'macos';
+  }
+  if (userAgent.includes('linux') || platform.includes('linux')) {
+    return 'linux';
+  }
+  return 'unknown';
+}
+
+/**
+ * Get the appropriate slicer protocol for the current OS
+ */
+export function getSlicerProtocol(): string {
+  const platform = detectPlatform();
+
+  switch (platform) {
+    case 'windows':
+      return 'bambustudio://';
+    case 'macos':
+    case 'linux':
+    default:
+      return 'bambustudioopen://';
+  }
+}
+
+/**
+ * Open a URL in Bambu Studio slicer
+ * @param downloadUrl - The URL to the file to open (will be encoded)
+ */
+export function openInSlicer(downloadUrl: string): void {
+  const protocol = getSlicerProtocol();
+  window.location.href = `${protocol}${encodeURIComponent(downloadUrl)}`;
+}
+
+/**
+ * Build a full download URL for a file
+ * @param path - The API path (e.g., from api.getArchiveForSlicer())
+ */
+export function buildDownloadUrl(path: string): string {
+  return `${window.location.origin}${path}`;
+}
+
+/**
+ * Convenience function to open an archive in the slicer
+ * @param path - The API path to the archive
+ */
+export function openArchiveInSlicer(path: string): void {
+  const downloadUrl = buildDownloadUrl(path);
+  openInSlicer(downloadUrl);
+}

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-3umWYOC3.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-BuWV4aNb.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-CCbBv2VC.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CtglbIix.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BuWV4aNb.css">
+    <script type="module" crossorigin src="/assets/index-3umWYOC3.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CCbBv2VC.css">
   </head>
   <body>
     <div id="root"></div>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor