Browse Source

Merge branch '0.2.1b' into codex/add-systemd-updater-script

MartinNYHC 3 months ago
parent
commit
ee4def24b8
63 changed files with 6644 additions and 2502 deletions
  1. 28 0
      CHANGELOG.md
  2. 1 0
      CONTRIBUTING.md
  3. 114 2
      backend/app/api/routes/archives.py
  4. 1 1
      backend/app/api/routes/filaments.py
  5. 56 0
      backend/app/api/routes/library.py
  6. 3 2
      backend/app/api/routes/printers.py
  7. 37 0
      backend/app/core/auth.py
  8. 1 1
      backend/app/core/config.py
  9. 6 0
      backend/app/core/database.py
  10. 4 0
      backend/app/main.py
  11. 3 0
      backend/app/models/spool.py
  12. 2 0
      backend/app/schemas/spool.py
  13. 30 3
      backend/app/services/bambu_mqtt.py
  14. 5 5
      backend/app/services/print_scheduler.py
  15. 77 9
      backend/app/services/usage_tracker.py
  16. 40 22
      backend/app/utils/threemf_tools.py
  17. 3 3
      backend/tests/integration/test_endpoint_auth.py
  18. 9 9
      backend/tests/integration/test_filaments_api.py
  19. 572 0
      backend/tests/unit/services/test_bambu_mqtt.py
  20. 363 11
      backend/tests/unit/test_scheduler_ams_mapping.py
  21. 184 0
      backend/tests/unit/test_usage_tracker.py
  22. 146 2067
      frontend/package-lock.json
  23. 0 2
      frontend/package.json
  24. 3 3
      frontend/src/__tests__/components/FileManagerModal.test.tsx
  25. 2 2
      frontend/src/__tests__/components/PrinterQueueWidget.test.tsx
  26. 135 0
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  27. 306 2
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  28. 437 0
      frontend/src/__tests__/utils/date.test.ts
  29. 43 0
      frontend/src/__tests__/utils/file.test.ts
  30. 17 4
      frontend/src/api/client.ts
  31. 1 1
      frontend/src/components/AssignSpoolModal.tsx
  32. 6 2
      frontend/src/components/FilamentHoverCard.tsx
  33. 2 8
      frontend/src/components/FileManagerModal.tsx
  34. 3 25
      frontend/src/components/GitHubBackupSettings.tsx
  35. 20 8
      frontend/src/components/ModelViewerModal.tsx
  36. 1 6
      frontend/src/components/PendingUploadsPanel.tsx
  37. 3 1
      frontend/src/components/PrintModal/FilamentMapping.tsx
  38. 2 16
      frontend/src/components/PrinterQueueWidget.tsx
  39. 9 1
      frontend/src/components/SpoolFormModal.tsx
  40. 55 9
      frontend/src/components/spool-form/AdditionalSection.tsx
  41. 4 4
      frontend/src/components/spool-form/ColorSection.tsx
  42. 2 2
      frontend/src/components/spool-form/FilamentSection.tsx
  43. 10 0
      frontend/src/components/spool-form/types.ts
  44. 8 11
      frontend/src/hooks/useFilamentMapping.ts
  45. 4 1
      frontend/src/i18n/index.ts
  46. 16 0
      frontend/src/i18n/locales/de.ts
  47. 16 0
      frontend/src/i18n/locales/en.ts
  48. 16 0
      frontend/src/i18n/locales/fr.ts
  49. 18 2
      frontend/src/i18n/locales/it.ts
  50. 16 0
      frontend/src/i18n/locales/ja.ts
  51. 3441 0
      frontend/src/i18n/locales/pt-BR.ts
  52. 39 22
      frontend/src/pages/ArchivesPage.tsx
  53. 1 8
      frontend/src/pages/FileManagerPage.tsx
  54. 5 2
      frontend/src/pages/PrintersPage.tsx
  55. 2 19
      frontend/src/pages/ProfilesPage.tsx
  56. 200 191
      frontend/src/pages/QueuePage.tsx
  57. 64 3
      frontend/src/utils/date.ts
  58. 20 0
      frontend/src/utils/file.ts
  59. 30 10
      frontend/src/utils/slicer.ts
  60. 0 0
      static/assets/index-DF7TfzH1.css
  61. 0 0
      static/assets/index-Dnmq8_Ro.js
  62. 0 0
      static/assets/index-EqFdfChN.css
  63. 2 2
      static/index.html

+ 28 - 0
CHANGELOG.md

@@ -2,6 +2,34 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.2.1b] - Not released
+
+### Fixed
+- **Nozzle Mapping Uses Wrong Source in 3MF Files** — The `extract_nozzle_mapping_from_3mf()` function used `filament_nozzle_map` (user preference) as the primary source for nozzle assignments. BambuStudio's "Auto For Flush" mode overrides user preferences at slice time, so the actual assignment lives in the `group_id` attribute on `<filament>` elements in `slice_info.config`. Now uses `group_id` as the primary source and falls back to `filament_nozzle_map` only when `group_id` is not present.
+- **Print Scheduler Hard-Filters Nozzle When No Trays on Target Nozzle** — On dual-nozzle printers, the scheduler enforced a strict nozzle filter when matching filaments. If a slicer filament was assigned to a nozzle with no AMS trays (e.g., only external spool on left nozzle), the match failed even though the filament existed on the other nozzle. Now falls back to unfiltered matching when no trays exist on the target nozzle.
+- **Print Scheduler External Spool Ignores Nozzle Assignment** — The external spool fallback in the scheduler always mapped to extruder 0 (right), ignoring the slicer's nozzle assignment. Now uses the 3MF nozzle mapping to select the correct extruder for external spool matches.
+- **ams_extruder_map Race Condition on Printer Status API** — The `/printers/{id}/status` endpoint read `ams_extruder_map` from the MQTT state without checking if the AMS data had been received yet. On fresh connections before the first AMS push-all, this returned an empty map — causing the frontend nozzle filter to show all trays as unfiltered. Now returns an empty object gracefully and the frontend disables nozzle filtering until the map is populated.
+- **Filament Mapping Frontend Ignores Nozzle for External Spools** — The `useFilamentMapping` hook always set `extruder_id: 0` for external spool matches. Now uses the nozzle mapping from the 3MF file to determine the correct extruder.
+- **AMS-HT Global Tray ID Computed Wrong on Printer Card** — The PrintersPage computed AMS-HT tray IDs using `ams_id * 4 + slot` (giving 512+), but AMS-HT units use their raw `ams_id` (128-135) as the global tray ID. Now uses `ams_id` directly for AMS-HT units.
+- **Filament Mapping Dropdown Shows Wrong Nozzle Trays** — The FilamentMapping dropdown filtered by `extruder_id` using strict equality, but `extruder_id` could be `undefined` for printers that hadn't reported their AMS extruder map yet. This caused all trays to be hidden. Now skips nozzle filtering when `extruder_id` is undefined.
+- **Cancelled Print Usage Tracking Uses Stale Progress/Layer** — When a print was cancelled, the usage tracker read `mc_percent` and `layer_num` from the printer's MQTT state — but by the time the `on_print_complete` callback ran, the printer had already reset these to 0. Now captures the last valid progress and layer values during printing, and the usage tracker reads these captured values on cancellation for accurate partial usage.
+- **H2D Tray Disambiguation Triggers on Single-Nozzle Printers** — The `tray_now <= 3` check for H2D dual-nozzle disambiguation matched any printer loading from AMS 0 (trays 0-3). On P2S, X1C, and X1E with multiple AMS units, this caused warning log spam every second. Now uses a persistent `_is_dual_nozzle` flag detected from `device.extruder.info` (>= 2 entries), which only dual-nozzle printers (H2D, H2D Pro) report.
+- **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
+- **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
+
+- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
+- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
+- **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
+- **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.
+
+### Changed
+- **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.
+
+### Improved
+- **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
+- **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.
+
+
 ## [0.2.0] - 2026-02-17
 
 ### New Features

+ 1 - 0
CONTRIBUTING.md

@@ -168,6 +168,7 @@ Translations live in `frontend/src/i18n/locales/`:
 | `de.ts` | German |
 | `fr.ts` | French |
 | `ja.ts` | Japanese |
+| `pt-BR.ts` | Brazilian Portuguese |
 
 ### Adding New Strings
 

+ 114 - 2
backend/app/api/routes/archives.py

@@ -1074,7 +1074,63 @@ async def download_archive_with_filename(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
+    """Download the 3MF file with filename in URL."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    return FileResponse(
+        path=file_path,
+        filename=archive.filename,
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/{archive_id}/slicer-token")
+async def create_archive_slicer_token(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Create a short-lived download token for opening files in slicer applications.
+
+    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send
+    auth headers, so they use this token in the URL path instead.
+    """
+    from backend.app.core.auth import create_slicer_download_token
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    token = create_slicer_download_token("archive", archive_id)
+    return {"token": token}
+
+
+@router.get("/{archive_id}/dl/{token}/{filename}")
+async def download_archive_for_slicer(
+    archive_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download 3MF file using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /{archive_id}/slicer-token.
+    Filename is at the end of the URL so slicers can detect the file format.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "archive", archive_id):
+        raise HTTPException(403, "Invalid or expired download token")
+
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive:
@@ -3093,7 +3149,63 @@ async def download_source_3mf_for_slicer(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
+    """Download source 3MF with filename in URL."""
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    source_path = settings.base_dir / archive.source_3mf_path
+    if not source_path.exists():
+        raise HTTPException(404, "Source 3MF file not found on disk")
+
+    return FileResponse(
+        path=source_path,
+        filename=filename if filename.endswith(".3mf") else f"{filename}.3mf",
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/{archive_id}/source-slicer-token")
+async def create_source_slicer_token(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Create a short-lived download token for opening source 3MF in slicer."""
+    from backend.app.core.auth import create_slicer_download_token
+
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    token = create_slicer_download_token("source", archive_id)
+    return {"token": token}
+
+
+@router.get("/{archive_id}/source-dl/{token}/{filename}")
+async def download_source_3mf_for_slicer_with_token(
+    archive_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download source 3MF using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /{archive_id}/source-slicer-token.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "source", archive_id):
+        raise HTTPException(403, "Invalid or expired download token")
+
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     if not archive:

+ 1 - 1
backend/app/api/routes/filaments.py

@@ -14,7 +14,7 @@ from backend.app.schemas.filament import (
     FilamentUpdate,
 )
 
-router = APIRouter(prefix="/filaments", tags=["filaments"])
+router = APIRouter(prefix="/filament-catalog", tags=["filament-catalog"])
 
 
 @router.get("/", response_model=list[FilamentResponse])

+ 56 - 0
backend/app/api/routes/library.py

@@ -2138,6 +2138,62 @@ async def download_file(
     )
 
 
+@router.post("/files/{file_id}/slicer-token")
+async def create_library_slicer_token(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
+    """Create a short-lived download token for opening files in slicer applications.
+
+    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send
+    auth headers, so they use this token in the URL path instead.
+    """
+    from backend.app.core.auth import create_slicer_download_token
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    token = create_slicer_download_token("library", file_id)
+    return {"token": token}
+
+
+@router.get("/files/{file_id}/dl/{token}/{filename}")
+async def download_library_file_for_slicer(
+    file_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download a library file using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /files/{file_id}/slicer-token.
+    Filename is at the end of the URL so slicers can detect the file format.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "library", file_id):
+        raise HTTPException(status_code=403, detail="Invalid or expired download token")
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    abs_path = to_absolute_path(file.file_path)
+    if not abs_path or not abs_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    return FastAPIFileResponse(
+        str(abs_path),
+        filename=file.filename,
+        media_type="application/octet-stream",
+    )
+
+
 @router.get("/files/{file_id}/thumbnail")
 async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
     """Get a file's thumbnail."""

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

@@ -402,8 +402,9 @@ async def get_printer_status(
 
     # Get AMS mapping from raw_data (which AMS is connected to which nozzle)
     ams_mapping = raw_data.get("ams_mapping", [])
-    # Get per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
-    ams_extruder_map = raw_data.get("ams_extruder_map", {})
+    # Get per-AMS extruder map from state attribute (not raw_data, to avoid race condition
+    # where raw_data gets replaced during MQTT updates and ams_extruder_map is temporarily missing)
+    ams_extruder_map = state.ams_extruder_map or {}
     logger.debug("API returning ams_mapping: %s, ams_extruder_map: %s", ams_mapping, ams_extruder_map)
 
     # tray_now from MQTT is already a global tray ID: (ams_id * 4) + slot_id

+ 37 - 0
backend/app/core/auth.py

@@ -98,6 +98,43 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days
 # HTTP Bearer token
 security = HTTPBearer(auto_error=False)
 
+# --- Slicer download tokens ---
+# Short-lived tokens for slicer protocol handlers that can't send auth headers.
+# Maps token → (resource_key, expiry). resource_key = "archive:{id}" or "library:{id}".
+_slicer_tokens: dict[str, tuple[str, datetime]] = {}
+SLICER_TOKEN_EXPIRE_MINUTES = 5
+
+
+def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
+    """Create a short-lived download token for slicer protocol handlers."""
+    # Cleanup expired tokens
+    now = datetime.utcnow()
+    expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now]
+    for k in expired:
+        del _slicer_tokens[k]
+
+    token = secrets.token_urlsafe(24)
+    resource_key = f"{resource_type}:{resource_id}"
+    _slicer_tokens[token] = (resource_key, now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES))
+    return token
+
+
+def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
+    """Verify a slicer download token is valid for the given resource."""
+    entry = _slicer_tokens.get(token)
+    if not entry:
+        return False
+    resource_key, expiry = entry
+    if datetime.utcnow() > expiry:
+        del _slicer_tokens[token]
+        return False
+    expected_key = f"{resource_type}:{resource_id}"
+    if resource_key != expected_key:
+        return False
+    # Token is single-use
+    del _slicer_tokens[token]
+    return True
+
 
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.0"
+APP_VERSION = "0.2.1b"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

+ 6 - 0
backend/app/core/database.py

@@ -1185,6 +1185,12 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Create spool_usage_history table for filament consumption tracking
     try:
         await conn.execute(

+ 4 - 0
backend/app/main.py

@@ -3356,6 +3356,10 @@ PUBLIC_API_PATTERNS = [
     # Camera (streams loaded via <img> tag)
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
+    # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
+    # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
+    # download token in the URL path instead.
+    "/dl/",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
 ]
 
 

+ 3 - 0
backend/app/models/spool.py

@@ -19,6 +19,9 @@ class Spool(Base):
     brand: Mapped[str | None] = mapped_column(String(100))  # "Polymaker"
     label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)
     core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)
+    core_weight_catalog_id: Mapped[int | None] = mapped_column(
+        Integer
+    )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer

+ 2 - 0
backend/app/schemas/spool.py

@@ -11,6 +11,7 @@ class SpoolBase(BaseModel):
     brand: str | None = None
     label_weight: int = 1000
     core_weight: int = 250
+    core_weight_catalog_id: int | None = None
     weight_used: float = 0
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
@@ -35,6 +36,7 @@ class SpoolUpdate(BaseModel):
     brand: str | None = None
     label_weight: int | None = None
     core_weight: int | None = None
+    core_weight_catalog_id: int | None = None
     weight_used: float | None = None
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None

+ 30 - 3
backend/app/services/bambu_mqtt.py

@@ -279,6 +279,9 @@ class BambuMQTTClient:
         self._was_running: bool = False  # Track if we've seen RUNNING state for current print
         self._completion_triggered: bool = False  # Prevent duplicate completion triggers
         self._timelapse_during_print: bool = False  # Track if timelapse was active during this print
+        self._last_valid_progress: float = 0.0  # Last non-zero progress (firmware resets on cancel)
+        self._last_valid_layer_num: int = 0  # Last non-zero layer (firmware resets on cancel)
+        self._is_dual_nozzle: bool = False  # Set when device.extruder.info has >= 2 entries
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
@@ -530,6 +533,16 @@ class BambuMQTTClient:
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                 )
 
+            # Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it)
+            # device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro)
+            if not self._is_dual_nozzle and "device" in print_data:
+                dev = print_data.get("device")
+                if isinstance(dev, dict):
+                    ext_info = dev.get("extruder", {}).get("info", [])
+                    if isinstance(ext_info, list) and len(ext_info) >= 2:
+                        self._is_dual_nozzle = True
+                        logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
+
             # Handle AMS data that comes inside print key
             if "ams" in print_data:
                 try:
@@ -912,7 +925,9 @@ class BambuMQTTClient:
 
                 # H2D dual-nozzle printers report only slot number (0-3), not global tray ID
                 # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
-                if parsed_tray_now >= 0 and parsed_tray_now <= 3:
+                # Single-nozzle printers (X1C, P2S, etc.) always report global IDs, even with multiple AMS
+                ams_map = self.state.ams_extruder_map
+                if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
                     # 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:
@@ -945,7 +960,8 @@ class BambuMQTTClient:
                         if snow_tray is not None and snow_tray != 255:
                             # snow_tray is already normalized to global ID
                             # Verify the slot matches what we see in tray_now
-                            snow_slot = snow_tray % 4 if snow_tray < 128 else -1
+                            # Regular AMS: slot = global_id % 4; AMS HT (128-135): single slot = 0
+                            snow_slot = snow_tray % 4 if snow_tray < 128 else (0 if snow_tray <= 135 else -1)
                             if snow_slot == parsed_tray_now:
                                 if self.state.tray_now != snow_tray:
                                     logger.debug(
@@ -962,7 +978,6 @@ class BambuMQTTClient:
                                 self.state.tray_now = snow_tray
                         else:
                             # Fallback: snow not available, use ams_extruder_map (less reliable)
-                            ams_map = self.state.ams_extruder_map
                             # Find ALL AMS units on the active extruder
                             ams_on_extruder = []
                             for ams_id_str, ext_id in ams_map.items():
@@ -1211,6 +1226,9 @@ class BambuMQTTClient:
         if "subtask_id" in data:
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
+            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
+            if self.state.progress > 0:
+                self._last_valid_progress = self.state.progress
             self.state.progress = float(data["mc_percent"])
         if "mc_remaining_time" in data:
             self.state.remaining_time = int(data["mc_remaining_time"])
@@ -1225,6 +1243,9 @@ class BambuMQTTClient:
         if "layer_num" in data:
             new_layer = int(data["layer_num"])
             old_layer = self.state.layer_num
+            # Save last non-zero layer for usage tracking (firmware resets to 0 on cancel)
+            if old_layer > 0:
+                self._last_valid_layer_num = old_layer
             self.state.layer_num = new_layer
             # Trigger layer change callback if layer increased
             if new_layer > old_layer and self.on_layer_change:
@@ -1984,6 +2005,9 @@ class BambuMQTTClient:
             # Reset completion tracking for new print
             self._was_running = True
             self._completion_triggered = False
+            # Reset last valid progress/layer for usage tracking
+            self._last_valid_progress = 0.0
+            self._last_valid_layer_num = 0
             # Initialize timelapse tracking based on current state
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
             # so self.state.timelapse may already be set from this message.
@@ -2067,6 +2091,9 @@ class BambuMQTTClient:
                     "timelapse_was_active": timelapse_was_active,
                     "hms_errors": hms_errors_data,
                     "ams_mapping": self._captured_ams_mapping,
+                    # Last valid progress/layer before firmware reset (for partial usage tracking)
+                    "last_progress": self._last_valid_progress,
+                    "last_layer_num": self._last_valid_layer_num,
                 }
             )
             self._captured_ams_mapping = None

+ 5 - 5
backend/app/services/print_scheduler.py

@@ -558,7 +558,7 @@ class PrintScheduler:
                         "is_ht": False,
                         "is_external": True,
                         "global_tray_id": tray_id,
-                        "extruder_id": (tray_id - 254) if ams_extruder_map else None,
+                        "extruder_id": (255 - tray_id) if ams_extruder_map else None,
                     }
                 )
 
@@ -634,12 +634,12 @@ class PrintScheduler:
             # Get available trays (not already used)
             available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
 
-            # Nozzle-aware filtering: restrict to trays on the correct nozzle
+            # Nozzle-aware filtering: restrict to trays on the correct nozzle.
+            # Hard filter — cross-nozzle assignment causes print failures
+            # ("position of left hotend is abnormal"), so never fall back.
             req_nozzle_id = req.get("nozzle_id")
             if req_nozzle_id is not None:
-                nozzle_filtered = [f for f in available if f.get("extruder_id") == req_nozzle_id]
-                if nozzle_filtered:
-                    available = nozzle_filtered
+                available = [f for f in available if f.get("extruder_id") == req_nozzle_id]
 
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:

+ 77 - 9
backend/app/services/usage_tracker.py

@@ -22,6 +22,47 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
 logger = logging.getLogger(__name__)
 
 
+def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
+    """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
+
+    The printer's MQTT mapping field is an array indexed by slicer filament slot
+    (0-based). Each value uses snow encoding: ams_hw_id * 256 + local_slot.
+    65535 means unmapped.
+
+    Returns a list of bambuddy global tray IDs (or -1 for unmapped), or None if
+    no valid mappings found.
+    """
+    if not isinstance(mapping_raw, list) or not mapping_raw:
+        return None
+
+    result = []
+    for value in mapping_raw:
+        if not isinstance(value, int) or value >= 65535:
+            result.append(-1)
+            continue
+
+        ams_hw_id = value >> 8
+        slot = value & 0xFF
+
+        if 0 <= ams_hw_id <= 3:
+            # Regular AMS: sequential global ID
+            result.append(ams_hw_id * 4 + (slot & 0x03))
+        elif 128 <= ams_hw_id <= 135:
+            # AMS-HT: global ID is the hardware ID (one slot per unit)
+            result.append(ams_hw_id)
+        elif ams_hw_id in (254, 255):
+            # External spool
+            result.append(254 if slot != 255 else 255)
+        else:
+            result.append(-1)
+
+    # Only return if at least one valid mapping exists
+    if all(v < 0 for v in result):
+        return None
+
+    return result
+
+
 @dataclass
 class PrintSession:
     printer_id: int
@@ -167,6 +208,8 @@ async def on_print_complete(
             db,
             ams_mapping=ams_mapping,
             tray_now_at_start=session.tray_now_at_start if session else -1,
+            last_progress=data.get("last_progress", 0.0),
+            last_layer_num=data.get("last_layer_num", 0),
         )
         results.extend(threemf_results)
 
@@ -276,6 +319,8 @@ async def _track_from_3mf(
     db: AsyncSession,
     ams_mapping: list[int] | None = None,
     tray_now_at_start: int = -1,
+    last_progress: float = 0.0,
+    last_layer_num: int = 0,
 ) -> list[dict]:
     """Track usage from 3MF per-filament slicer data (primary path).
 
@@ -285,9 +330,10 @@ async def _track_from_3mf(
 
     Slot-to-tray mapping priority:
     1. Stored ams_mapping from print command (reprints/direct prints)
-    2. Queue item ams_mapping (for queue-initiated prints)
-    3. tray_now from printer state (for single-filament non-queue prints)
-    4. Default mapping: slot_id - 1 = global_tray_id (last resort)
+    2. MQTT mapping field from printer state (universal, all print sources)
+    3. Queue item ams_mapping (for queue-initiated prints)
+    4. tray_now from printer state (for single-filament non-queue prints)
+    5. Default mapping: slot_id - 1 = global_tray_id (last resort)
     """
     from backend.app.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
@@ -313,10 +359,25 @@ async def _track_from_3mf(
     logger.info("[UsageTracker] 3MF: archive %s, filament_usage=%s", archive_id, filament_usage)
 
     # --- Resolve slot-to-tray mapping ---
+    mapping_source = None
+
     # 1. Use stored ams_mapping from the print command (reprints/direct prints)
     slot_to_tray = ams_mapping
+    if slot_to_tray:
+        mapping_source = "print_cmd"
 
-    # 2. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
+    # 2. Try MQTT mapping field from printer state (universal, all print sources)
+    if not slot_to_tray:
+        state = printer_manager.get_status(printer_id)
+        raw_data = getattr(state, "raw_data", None) if state else None
+        if raw_data:
+            mqtt_mapping = raw_data.get("mapping")
+            decoded = _decode_mqtt_mapping(mqtt_mapping)
+            if decoded:
+                slot_to_tray = decoded
+                mapping_source = "mqtt"
+
+    # 3. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
     if not slot_to_tray:
         queue_result = await db.execute(
             select(PrintQueueItem)
@@ -327,13 +388,14 @@ async def _track_from_3mf(
         if queue_item and queue_item.ams_mapping:
             try:
                 slot_to_tray = json.loads(queue_item.ams_mapping)
+                mapping_source = "queue"
             except (json.JSONDecodeError, TypeError):
                 pass
 
     logger.info(
         "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
         slot_to_tray,
-        "print_cmd" if ams_mapping else ("queue" if slot_to_tray else "none"),
+        mapping_source or "none",
     )
 
     # 3. For single-filament non-queue prints, use tray_now from printer state
@@ -374,6 +436,10 @@ async def _track_from_3mf(
     else:
         state = printer_manager.get_status(printer_id)
         progress = state.progress if state else 0
+        # Firmware resets progress to 0 on cancel — use last valid progress captured during print
+        if progress <= 0 and last_progress > 0:
+            progress = last_progress
+            logger.info("[UsageTracker] 3MF: using last_progress=%.1f (firmware reset current to 0)", last_progress)
         scale = max(0.0, min(progress / 100.0, 1.0))
 
     # Per-layer gcode accuracy for partial prints
@@ -381,6 +447,10 @@ async def _track_from_3mf(
     if status != "completed":
         state = printer_manager.get_status(printer_id)
         current_layer = state.layer_num if state else 0
+        # Firmware resets layer_num to 0 on cancel — use last valid layer captured during print
+        if current_layer <= 0 and last_layer_num > 0:
+            current_layer = last_layer_num
+            logger.info("[UsageTracker] 3MF: using last_layer_num=%d (firmware reset current to 0)", last_layer_num)
         if current_layer > 0:
             try:
                 from backend.app.utils.threemf_tools import (
@@ -509,10 +579,8 @@ async def _track_from_3mf(
         # Determine mapping source for debug logging
         if tray_now_override is not None:
             map_src = ", tray_now"
-        elif slot_to_tray and ams_mapping:
-            map_src = ", print_cmd_map"
-        elif slot_to_tray:
-            map_src = ", queue_map"
+        elif mapping_source:
+            map_src = f", {mapping_source}_map"
         else:
             map_src = ""
         logger.info(

+ 40 - 22
backend/app/utils/threemf_tools.py

@@ -265,15 +265,18 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
 
 
 def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
-    """Extract per-slot nozzle/extruder mapping from a 3MF file's project settings.
+    """Extract per-slot nozzle/extruder mapping from a 3MF file.
 
     On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
-    specific nozzle. This reads the slicer's nozzle assignment from
-    Metadata/project_settings.config.
+    specific nozzle. The slicer may override user preferences when using "Auto For
+    Flush" mode, so the actual assignment comes from slice_info.config group_id
+    attributes, not from the user's filament_nozzle_map preference.
 
-    Translation chain:
-        filament_nozzle_map[slot_id - 1] -> slicer extruder index
-        physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left)
+    Priority:
+        1. group_id on <filament> elements in slice_info.config (actual assignment)
+        2. filament_nozzle_map in project_settings.config (user preference fallback)
+
+    Both are mapped through physical_extruder_map to get MQTT extruder IDs (0=right, 1=left).
 
     Args:
         zf: An open ZipFile of the 3MF archive
@@ -289,33 +292,48 @@ def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | Non
         content = zf.read("Metadata/project_settings.config").decode()
         data = json.loads(content)
 
-        filament_nozzle_map = data.get("filament_nozzle_map")
         physical_extruder_map = data.get("physical_extruder_map")
+        if not physical_extruder_map or len(physical_extruder_map) <= 1:
+            return None  # Single-nozzle printer
 
-        if not filament_nozzle_map or not physical_extruder_map:
+        # Priority 1: Use group_id from slice_info filament elements.
+        # This reflects the actual slicer assignment (respects "Auto For Flush").
+        nozzle_mapping: dict[int, int] = {}
+        if "Metadata/slice_info.config" in zf.namelist():
+            si_content = zf.read("Metadata/slice_info.config").decode()
+            si_root = ET.fromstring(si_content)
+            for filament_elem in si_root.findall(".//filament"):
+                group_id_str = filament_elem.get("group_id")
+                filament_id_str = filament_elem.get("id")
+                if group_id_str is not None and filament_id_str:
+                    try:
+                        group_id = int(group_id_str)
+                        slot_id = int(filament_id_str)
+                        if group_id < len(physical_extruder_map):
+                            nozzle_mapping[slot_id] = int(physical_extruder_map[group_id])
+                    except (ValueError, TypeError, IndexError):
+                        pass
+
+        if nozzle_mapping:
+            return nozzle_mapping
+
+        # Priority 2: Fall back to filament_nozzle_map (user preference).
+        # This is correct when the user manually assigned nozzles, but may be
+        # wrong when the slicer overrides via "Auto For Flush".
+        filament_nozzle_map = data.get("filament_nozzle_map")
+        if not filament_nozzle_map:
             return None
 
-        # Build slot_id (1-based) -> extruder_id mapping
-        nozzle_mapping: dict[int, int] = {}
         for i, slicer_ext_str in enumerate(filament_nozzle_map):
             slot_id = i + 1
             try:
                 slicer_ext = int(slicer_ext_str)
                 if slicer_ext < len(physical_extruder_map):
-                    extruder_id = int(physical_extruder_map[slicer_ext])
-                    nozzle_mapping[slot_id] = extruder_id
+                    nozzle_mapping[slot_id] = int(physical_extruder_map[slicer_ext])
             except (ValueError, TypeError, IndexError):
-                pass  # Skip slots with unparseable nozzle mapping
-
-        if not nozzle_mapping:
-            return None
-
-        # If all slots map to the same extruder, this is a single-nozzle printer
-        unique_extruders = set(nozzle_mapping.values())
-        if len(unique_extruders) <= 1:
-            return None
+                pass
 
-        return nozzle_mapping
+        return nozzle_mapping if nozzle_mapping else None
     except Exception:
         return None
 

+ 3 - 3
backend/tests/integration/test_endpoint_auth.py

@@ -70,7 +70,7 @@ class TestEndpointAuthenticationEnforcement:
     async def test_filaments_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
         """Verify filaments list is accessible when auth is disabled."""
         with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
-            response = await async_client.get("/api/v1/filaments/")
+            response = await async_client.get("/api/v1/filament-catalog/")
             assert response.status_code == 200
 
     @pytest.mark.asyncio
@@ -153,7 +153,7 @@ class TestAuthenticationPatterns:
         """Verify require_permission_if_auth_enabled allows access when auth disabled."""
         with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
             # Test a protected endpoint
-            response = await async_client.get("/api/v1/filaments/")
+            response = await async_client.get("/api/v1/filament-catalog/")
             assert response.status_code == 200
 
     @pytest.mark.asyncio
@@ -162,7 +162,7 @@ class TestAuthenticationPatterns:
         """Verify multiple protected endpoints are accessible when auth is disabled."""
         with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
             endpoints = [
-                "/api/v1/filaments/",
+                "/api/v1/filament-catalog/",
                 "/api/v1/external-links/",
                 "/api/v1/notifications/",
                 "/api/v1/maintenance/types",

+ 9 - 9
backend/tests/integration/test_filaments_api.py

@@ -5,7 +5,7 @@ from httpx import AsyncClient
 
 
 class TestFilamentsAPI:
-    """Integration tests for /api/v1/filaments/ endpoints."""
+    """Integration tests for /api/v1/filament-catalog/ (material types) endpoints."""
 
     @pytest.fixture
     async def filament_factory(self, db_session):
@@ -36,7 +36,7 @@ class TestFilamentsAPI:
     @pytest.mark.integration
     async def test_list_filaments_empty(self, async_client: AsyncClient):
         """Verify empty list when no filaments exist."""
-        response = await async_client.get("/api/v1/filaments/")
+        response = await async_client.get("/api/v1/filament-catalog/")
         assert response.status_code == 200
         assert isinstance(response.json(), list)
 
@@ -45,7 +45,7 @@ class TestFilamentsAPI:
     async def test_list_filaments_with_data(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify list returns existing filaments."""
         await filament_factory(name="Test Filament")
-        response = await async_client.get("/api/v1/filaments/")
+        response = await async_client.get("/api/v1/filament-catalog/")
         assert response.status_code == 200
         data = response.json()
         assert any(f["name"] == "Test Filament" for f in data)
@@ -62,7 +62,7 @@ class TestFilamentsAPI:
             "brand": "Bambu",
             "cost_per_kg": 30.0,
         }
-        response = await async_client.post("/api/v1/filaments/", json=data)
+        response = await async_client.post("/api/v1/filament-catalog/", json=data)
         assert response.status_code == 200
         result = response.json()
         assert result["name"] == "New PETG"
@@ -73,7 +73,7 @@ class TestFilamentsAPI:
     async def test_get_filament(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify single filament can be retrieved."""
         filament = await filament_factory(name="Get Test")
-        response = await async_client.get(f"/api/v1/filaments/{filament.id}")
+        response = await async_client.get(f"/api/v1/filament-catalog/{filament.id}")
         assert response.status_code == 200
         assert response.json()["name"] == "Get Test"
 
@@ -81,7 +81,7 @@ class TestFilamentsAPI:
     @pytest.mark.integration
     async def test_get_filament_not_found(self, async_client: AsyncClient):
         """Verify 404 for non-existent filament."""
-        response = await async_client.get("/api/v1/filaments/9999")
+        response = await async_client.get("/api/v1/filament-catalog/9999")
         assert response.status_code == 404
 
     @pytest.mark.asyncio
@@ -90,7 +90,7 @@ class TestFilamentsAPI:
         """Verify filament can be updated."""
         filament = await filament_factory(name="Original")
         response = await async_client.patch(
-            f"/api/v1/filaments/{filament.id}", json={"name": "Updated", "cost_per_kg": 35.0}
+            f"/api/v1/filament-catalog/{filament.id}", json={"name": "Updated", "cost_per_kg": 35.0}
         )
         assert response.status_code == 200
         result = response.json()
@@ -102,8 +102,8 @@ class TestFilamentsAPI:
     async def test_delete_filament(self, async_client: AsyncClient, filament_factory, db_session):
         """Verify filament can be deleted."""
         filament = await filament_factory()
-        response = await async_client.delete(f"/api/v1/filaments/{filament.id}")
+        response = await async_client.delete(f"/api/v1/filament-catalog/{filament.id}")
         assert response.status_code == 200
         # Verify deleted
-        response = await async_client.get(f"/api/v1/filaments/{filament.id}")
+        response = await async_client.get(f"/api/v1/filament-catalog/{filament.id}")
         assert response.status_code == 404

+ 572 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -1205,3 +1205,575 @@ class TestRequestTopicAmsMapping:
         assert complete_data["status"] == "completed"
         # Mapping cleared after completion
         assert mqtt_client._captured_ams_mapping is None
+
+
+# ---------------------------------------------------------------------------
+# tray_now disambiguation helpers
+# ---------------------------------------------------------------------------
+
+
+def _ams_payload(tray_now, ams_units=None, tray_exist_bits=None):
+    """Build minimal print.ams payload for tray_now disambiguation tests."""
+    ams = {"tray_now": str(tray_now)}
+    if ams_units is not None:
+        ams["ams"] = ams_units
+    if tray_exist_bits is not None:
+        ams["tray_exist_bits"] = tray_exist_bits
+    return {"print": {"ams": ams}}
+
+
+def _extruder_info_payload(extruders):
+    """Build device.extruder.info payload (dual-nozzle detection + snow).
+
+    Each entry in *extruders* is a dict with at least ``id`` and ``snow``.
+    """
+    return {
+        "print": {
+            "device": {
+                "extruder": {
+                    "info": extruders,
+                }
+            }
+        }
+    }
+
+
+def _extruder_state_payload(state_val):
+    """Build device.extruder.state payload (active extruder via bit 8)."""
+    return {
+        "print": {
+            "device": {
+                "extruder": {
+                    "state": state_val,
+                }
+            }
+        }
+    }
+
+
+# ---------------------------------------------------------------------------
+# 1. Single-nozzle X1E — direct passthrough
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowSingleNozzleX1E:
+    """Single-nozzle, 1 AMS — tray_now is a direct passthrough."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_X1E",
+            access_code="12345678",
+        )
+
+    def test_tray_now_direct_passthrough_slot_0_to_3(self, mqtt_client):
+        """Each tray_now 0-3 maps 1:1 on single-nozzle printers."""
+        for slot in range(4):
+            mqtt_client._process_message(_ams_payload(slot))
+            assert mqtt_client.state.tray_now == slot
+
+    def test_tray_now_255_means_unloaded(self, mqtt_client):
+        """tray_now=255 means no filament loaded."""
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255
+
+    def test_single_extruder_does_not_trigger_dual_nozzle(self, mqtt_client):
+        """device.extruder.info with 1 entry must NOT set _is_dual_nozzle."""
+        mqtt_client._process_message(_extruder_info_payload([{"id": 0, "snow": 0xFF00FF}]))
+        assert mqtt_client._is_dual_nozzle is False
+
+    def test_last_loaded_tray_survives_unload(self, mqtt_client):
+        """Load tray 2, unload → last_loaded_tray stays 2."""
+        mqtt_client._process_message(_ams_payload(2))
+        assert mqtt_client.state.last_loaded_tray == 2
+
+        mqtt_client._process_message(_ams_payload(255))
+        assert mqtt_client.state.tray_now == 255
+        assert mqtt_client.state.last_loaded_tray == 2
+
+
+# ---------------------------------------------------------------------------
+# 2. Single-nozzle P2S — multiple AMS, global IDs pass through
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowSingleNozzleP2S:
+    """Single-nozzle, 2 AMS — global IDs 4-7 for AMS 1 pass through directly."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_P2S",
+            access_code="12345678",
+        )
+
+    def test_tray_now_ams1_global_ids_4_to_7(self, mqtt_client):
+        """tray_now 4-7 are global IDs for AMS 1 on single-nozzle printers."""
+        for global_id in range(4, 8):
+            mqtt_client._process_message(_ams_payload(global_id))
+            assert mqtt_client.state.tray_now == global_id
+
+    def test_tray_change_across_ams_units(self, mqtt_client):
+        """Switch from AMS 0 slot 1 → AMS 1 slot 2 (global 6)."""
+        mqtt_client._process_message(_ams_payload(1))
+        assert mqtt_client.state.tray_now == 1
+
+        mqtt_client._process_message(_ams_payload(6))
+        assert mqtt_client.state.tray_now == 6
+
+
+# ---------------------------------------------------------------------------
+# 3. H2D Pro — initial state detection
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DSetup:
+    """H2D Pro initial state detection."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+
+    def test_dual_nozzle_detected_from_extruder_info(self, mqtt_client):
+        """2 entries in device.extruder.info → _is_dual_nozzle=True."""
+        mqtt_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert mqtt_client._is_dual_nozzle is True
+
+    def test_ams_extruder_map_parsed_from_info_field(self, mqtt_client):
+        """AMS 0 info=2003 → right (ext 0), AMS 128 info=2104 → left (ext 1)."""
+        ams_units = [
+            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+        ]
+        payload = {
+            "print": {
+                "ams": {
+                    "ams": ams_units,
+                    "tray_now": "255",
+                    "tray_exist_bits": "1000f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # info=2003: bit8 = (2003>>8)&1 = 7&1 = 1 → extruder = 1-1 = 0 (right)
+        # info=2104: bit8 = (2104>>8)&1 = 8&1 = 0 → extruder = 1-0 = 1 (left)
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+
+    def test_dual_nozzle_detection_before_ams_in_same_message(self, mqtt_client):
+        """Dual-nozzle detection at line 538 happens before _handle_ams_data() at line 549.
+
+        If both arrive in the same message, tray_now disambiguation already uses dual-nozzle logic.
+        """
+        payload = {
+            "print": {
+                "device": {
+                    "extruder": {
+                        "info": [
+                            {"id": 0, "snow": 0xFF00FF},
+                            {"id": 1, "snow": 0xFF00FF},
+                        ],
+                        "state": 0x0001,
+                    }
+                },
+                "ams": {
+                    "ams": [
+                        {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                    ],
+                    "tray_now": "2",
+                    "tray_exist_bits": "f",
+                },
+            }
+        }
+        mqtt_client._process_message(payload)
+
+        # Dual-nozzle was detected; AMS 0 on right extruder (active by default);
+        # snow is 0xFF00FF (unloaded), so falls through to ams_extruder_map fallback.
+        # Single AMS on extruder 0 → global_id = 0*4+2 = 2
+        assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.tray_now == 2
+
+
+# ---------------------------------------------------------------------------
+# Shared H2D fixture for classes 4-8
+# ---------------------------------------------------------------------------
+
+
+class _H2DFixtureMixin:
+    """Mixin providing a pre-configured H2D Pro client."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST_H2D",
+            access_code="12345678",
+        )
+
+    @pytest.fixture
+    def h2d_client(self, mqtt_client):
+        """Pre-configure as H2D Pro: dual-nozzle + ams_extruder_map."""
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "device": {
+                        "extruder": {
+                            "info": [
+                                {"id": 0, "snow": 0xFF00FF},
+                                {"id": 1, "snow": 0xFF00FF},
+                            ],
+                            "state": 0x0001,  # right extruder active
+                        }
+                    },
+                    "ams": {
+                        "ams": [
+                            {"id": 0, "info": 2003, "tray": [{"id": i} for i in range(4)]},
+                            {"id": 128, "info": 2104, "tray": [{"id": 0}]},
+                        ],
+                        "tray_now": "255",
+                        "tray_exist_bits": "1000f",
+                    },
+                }
+            }
+        )
+        assert mqtt_client._is_dual_nozzle is True
+        assert mqtt_client.state.ams_extruder_map == {"0": 0, "128": 1}
+        return mqtt_client
+
+
+# ---------------------------------------------------------------------------
+# 4. H2D Snow field disambiguation
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DSnow(_H2DFixtureMixin):
+    """Snow field disambiguation (primary path)."""
+
+    def test_snow_disambiguates_ams0_slot(self, h2d_client):
+        """snow ext[0]=AMS 0 slot 2, tray_now='2' → global 2."""
+        # Send snow update FIRST (snow is parsed AFTER tray_now in the same message,
+        # so we need it in a prior message).
+        snow_val = 0 << 8 | 2  # AMS 0 slot 2 = raw 2
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_val},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(0) == 2
+
+        # Now send tray_now=2
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+    def test_snow_disambiguates_ams_ht_to_128(self, h2d_client):
+        """snow ext[1]=AMS HT (128), left active, tray_now='0' → global 128."""
+        # Snow: extruder 1 → AMS 128 slot 0
+        snow_val = 128 << 8 | 0  # = 32768
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": snow_val},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(1) == 128
+
+        # Switch to left extruder
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+        # tray_now="0" with left extruder active, snow says AMS HT (128)
+        # AMS HT snow_slot = 0 (single slot), parsed_tray_now = 0 → match
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+    def test_snow_updates_h2d_extruder_snow_state(self, h2d_client):
+        """Verify state.h2d_extruder_snow dict is populated correctly."""
+        snow_ext0 = 1 << 8 | 3  # AMS 1 slot 3 → global 7
+        snow_ext1 = 0 << 8 | 0  # AMS 0 slot 0 → global 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_ext0},
+                    {"id": 1, "snow": snow_ext1},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow[0] == 7
+        assert h2d_client.state.h2d_extruder_snow[1] == 0
+
+    def test_snow_unloaded_value(self, h2d_client):
+        """snow=0xFFFF (ams_id=255, slot=255) → 255 (unloaded)."""
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFFFF},
+                    {"id": 1, "snow": 0xFFFF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow[0] == 255
+        assert h2d_client.state.h2d_extruder_snow[1] == 255
+
+    def test_snow_initial_sentinel_not_stored(self, h2d_client):
+        """snow=0xFF00FF (firmware initial sentinel) is not parsed into h2d_extruder_snow."""
+        # 0xFF00FF has ams_id=0xFF00=65280 which doesn't match any branch
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        # Snow dict should remain empty (no matching branch)
+        assert h2d_client.state.h2d_extruder_snow == {}
+
+
+# ---------------------------------------------------------------------------
+# 5. H2D Pending target disambiguation
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
+    """Pending target disambiguation (when Bambuddy initiates load)."""
+
+    def test_pending_target_matches_slot(self, h2d_client):
+        """pending=5, tray_now='1' (5%4=1 matches) → tray_now=5."""
+        h2d_client.state.pending_tray_target = 5
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+        assert h2d_client.state.pending_tray_target is None  # cleared
+
+    def test_pending_target_slot_mismatch(self, h2d_client):
+        """pending=5, tray_now='2' → uses raw slot, clears pending."""
+        h2d_client.state.pending_tray_target = 5
+        h2d_client._process_message(_ams_payload(2))
+        # Slot 2 != 5%4=1 → mismatch, uses raw slot 2
+        assert h2d_client.state.tray_now == 2
+        assert h2d_client.state.pending_tray_target is None
+
+    def test_pending_target_takes_priority_over_snow(self, h2d_client):
+        """When both pending and snow are set, pending wins."""
+        # Set up snow for extruder 0 → AMS 0 slot 1 → global 1
+        snow_val = 0 << 8 | 1
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": snow_val},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        assert h2d_client.state.h2d_extruder_snow.get(0) == 1
+
+        # Set pending target to AMS 1 slot 1 (global 5)
+        h2d_client.state.pending_tray_target = 5
+        # tray_now="1" — matches pending (5%4=1), pending should win over snow
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+
+
+# ---------------------------------------------------------------------------
+# 6. H2D ams_extruder_map fallback
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DFallback(_H2DFixtureMixin):
+    """ams_extruder_map fallback (no pending, no snow)."""
+
+    def test_single_ams_on_extruder_computes_global_id(self, h2d_client):
+        """AMS 0 on right extruder, tray_now='2' → 0*4+2=2."""
+        # h2d_client has snow=0xFF00FF (unloaded) by default, so snow path skips
+        h2d_client._process_message(_ams_payload(2))
+        # AMS 0 is the only AMS on extruder 0 (right, active by default)
+        # Fallback: single AMS → global = 0*4+2 = 2
+        assert h2d_client.state.tray_now == 2
+
+    def test_multiple_ams_keeps_current_if_valid(self, h2d_client):
+        """Current tray matches slot → keeps it (multi-AMS on same extruder)."""
+        # Set up: two AMS units on the same extruder (right, ext 0)
+        h2d_client.state.ams_extruder_map = {"0": 0, "1": 0}
+        # Pre-set tray_now=5 (AMS 1 slot 1) — current_ams=1 which is in ams_on_extruder
+        h2d_client.state.tray_now = 5
+        # tray_now="1" → 5%4=1 matches → keep current=5
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 5
+
+    def test_no_ams_on_extruder_uses_raw_slot(self, h2d_client):
+        """No AMS mapped to the active extruder → raw slot as global ID."""
+        # All AMS on left extruder, but right is active
+        h2d_client.state.ams_extruder_map = {"0": 1, "128": 1}
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+
+# ---------------------------------------------------------------------------
+# 7. H2D Active extruder switching
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DActiveExtruder(_H2DFixtureMixin):
+    """Active extruder switching via device.extruder.state bit 8."""
+
+    def test_active_extruder_right_by_default(self, h2d_client):
+        """Initial state.active_extruder == 0 (right)."""
+        assert h2d_client.state.active_extruder == 0
+
+    def test_extruder_state_bit8_switches_to_left(self, h2d_client):
+        """state=0x100 → active_extruder=1 (left)."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+    def test_extruder_state_bit8_switches_back_to_right(self, h2d_client):
+        """Cycle 0 → 1 → 0."""
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        assert h2d_client.state.active_extruder == 1
+
+        h2d_client._process_message(_extruder_state_payload(0x0001))
+        assert h2d_client.state.active_extruder == 0
+
+    def test_extruder_switch_changes_tray_disambiguation(self, h2d_client):
+        """Snow on both extruders; switching active changes which snow is used."""
+        # Snow: ext 0 → AMS 0 slot 1 (global 1), ext 1 → AMS 128 slot 0 (global 128)
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 1},  # AMS 0 slot 1 → global 1
+                    {"id": 1, "snow": 128 << 8 | 0},  # AMS HT → global 128
+                ]
+            )
+        )
+
+        # Right active (default) — tray_now="1" → snow ext[0] says global 1
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 1
+
+        # Switch to left
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+
+        # Left active — tray_now="0" → snow ext[1] says AMS HT (128), slot 0 matches
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+
+# ---------------------------------------------------------------------------
+# 8. H2D Full multi-message sequences
+# ---------------------------------------------------------------------------
+
+
+class TestTrayNowDualNozzleH2DFullSequence(_H2DFixtureMixin):
+    """Multi-message sequences simulating real H2D Pro prints."""
+
+    def test_h2d_right_nozzle_ams0_lifecycle(self, h2d_client):
+        """Setup → load AMS 0 slot 1 → verify tray_now=1."""
+        # Snow update: extruder 0 loading AMS 0 slot 1
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 1},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        # Printer reports tray_now="1"
+        h2d_client._process_message(_ams_payload(1))
+        assert h2d_client.state.tray_now == 1
+        assert h2d_client.state.last_loaded_tray == 1
+
+    def test_h2d_left_nozzle_ams_ht_lifecycle(self, h2d_client):
+        """Setup → switch left → load AMS HT → verify tray_now=128."""
+        # Switch to left extruder
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+
+        # Snow: ext 1 → AMS HT slot 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0xFF00FF},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+
+        # Printer reports tray_now="0" (AMS HT single slot)
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+        assert h2d_client.state.last_loaded_tray == 128
+
+    def test_h2d_multi_color_alternating_nozzles(self, h2d_client):
+        """Multi-color print alternating between right and left nozzles.
+
+        Sequence:
+        1. Right loads AMS 0 slot 0 (tray=0)
+        2. Switch left, load AMS HT (tray=128)
+        3. Switch right, snow updates, load AMS 0 slot 2 (tray=2)
+        4. Unload (255)
+        """
+        # Step 1: Right extruder loads AMS 0 slot 0
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 0},
+                    {"id": 1, "snow": 0xFF00FF},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 0
+
+        # Step 2: Switch to left, load AMS HT
+        h2d_client._process_message(_extruder_state_payload(0x0100))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 0},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(0))
+        assert h2d_client.state.tray_now == 128
+
+        # Step 3: Switch back to right, load AMS 0 slot 2
+        h2d_client._process_message(_extruder_state_payload(0x0001))
+        h2d_client._process_message(
+            _extruder_info_payload(
+                [
+                    {"id": 0, "snow": 0 << 8 | 2},
+                    {"id": 1, "snow": 128 << 8 | 0},
+                ]
+            )
+        )
+        h2d_client._process_message(_ams_payload(2))
+        assert h2d_client.state.tray_now == 2
+
+        # Step 4: Unload
+        h2d_client._process_message(_ams_payload(255))
+        assert h2d_client.state.tray_now == 255
+        assert h2d_client.state.last_loaded_tray == 2

+ 363 - 11
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -474,12 +474,17 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
         assert result[0]["is_external"] is True
 
 
-def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
+def _make_3mf_zip(
+    project_settings: dict | None = None,
+    slice_info_xml: str | None = None,
+) -> zipfile.ZipFile:
     """Create an in-memory ZipFile mimicking a 3MF with project_settings.config."""
     buf = io.BytesIO()
     with zipfile.ZipFile(buf, "w") as zf:
         if project_settings is not None:
             zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
+        if slice_info_xml is not None:
+            zf.writestr("Metadata/slice_info.config", slice_info_xml)
     buf.seek(0)
     return zipfile.ZipFile(buf, "r")
 
@@ -487,8 +492,55 @@ def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
 class TestExtractNozzleMappingFrom3mf:
     """Test the extract_nozzle_mapping_from_3mf utility."""
 
-    def test_dual_nozzle_mapping(self):
-        """Should return slot->extruder mapping for dual-nozzle files."""
+    def test_group_id_priority_over_filament_nozzle_map(self):
+        """group_id from slice_info should override filament_nozzle_map from project_settings.
+
+        Real-world scenario: "Auto For Flush" mode sets filament_nozzle_map all to 0
+        (user preference) but the actual assignment in slice_info has different group_ids.
+        """
+        # filament_nozzle_map says all on slicer ext 0 → MQTT ext 1 (LEFT)
+        # But slice_info group_id says slot 6 → group 0 (LEFT), slot 12 → group 1 (RIGHT)
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="6" type="PLA" color="#56B7E6" used_g="1.84" group_id="0"/>
+            <filament id="12" type="PLA" color="#B39B84" used_g="1.76" group_id="1"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0"] * 12,
+                "physical_extruder_map": ["1", "0"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        # group_id 0 → physical_extruder_map[0] = 1 (LEFT)
+        # group_id 1 → physical_extruder_map[1] = 0 (RIGHT)
+        assert result == {6: 1, 12: 0}
+        zf.close()
+
+    def test_fallback_to_filament_nozzle_map_without_group_id(self):
+        """Should fall back to filament_nozzle_map when slice_info has no group_id."""
+        slice_info = """<?xml version="1.0" encoding="UTF-8"?>
+        <config>
+          <plate>
+            <filament id="1" type="PLA" color="#FF0000" used_g="5.0"/>
+          </plate>
+        </config>"""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1", "0"],
+                "physical_extruder_map": ["0", "1"],
+            },
+            slice_info_xml=slice_info,
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 0, 2: 1, 3: 0}
+        zf.close()
+
+    def test_fallback_to_filament_nozzle_map_without_slice_info(self):
+        """Should fall back to filament_nozzle_map when no slice_info.config exists."""
         zf = _make_3mf_zip(
             {
                 "filament_nozzle_map": ["0", "1", "0"],
@@ -500,7 +552,7 @@ class TestExtractNozzleMappingFrom3mf:
         zf.close()
 
     def test_single_nozzle_returns_none(self):
-        """All slots on same extruder should return None (single-nozzle)."""
+        """Single physical_extruder_map entry should return None (single-nozzle)."""
         zf = _make_3mf_zip(
             {
                 "filament_nozzle_map": ["0", "0", "0"],
@@ -519,7 +571,7 @@ class TestExtractNozzleMappingFrom3mf:
         zf.close()
 
     def test_missing_fields_returns_none(self):
-        """Missing filament_nozzle_map or physical_extruder_map should return None."""
+        """Missing physical_extruder_map should return None."""
         zf = _make_3mf_zip({"some_other_key": "value"})
         result = extract_nozzle_mapping_from_3mf(zf)
         assert result is None
@@ -562,8 +614,8 @@ class TestNozzleAwareMapping:
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [0, 4]
 
-    def test_nozzle_fallback_when_no_match(self, scheduler):
-        """Should fall back to unfiltered list when nozzle-filtered list is empty."""
+    def test_nozzle_hard_filter_no_fallback(self, scheduler):
+        """Hard filter: no fallback to wrong nozzle when target nozzle has no trays."""
         required = [
             {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
         ]
@@ -571,9 +623,9 @@ class TestNozzleAwareMapping:
             # Only a tray on the left nozzle, none on right
             {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},
         ]
-        # No trays on extruder 0, so fallback to unfiltered -> should still match
+        # No trays on extruder 0 — hard filter returns -1, no cross-nozzle fallback
         result = scheduler._match_filaments_to_slots(required, loaded)
-        assert result == [4]
+        assert result == [-1]
 
     def test_no_nozzle_id_skips_filtering(self, scheduler):
         """When nozzle_id is None, no nozzle filtering should be applied."""
@@ -620,7 +672,7 @@ class TestNozzleAwareMapping:
         assert result[0]["extruder_id"] is None
 
     def test_external_spool_extruder_id(self, scheduler):
-        """External spool should have extruder_id=0 when ams_extruder_map exists."""
+        """External spool 254 (Ext-L) should have extruder_id=1 (LEFT) when ams_extruder_map exists."""
 
         class MockStatus:
             raw_data = {
@@ -630,7 +682,8 @@ class TestNozzleAwareMapping:
 
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1
-        assert result[0]["extruder_id"] == 0
+        # Default vt_tray id=254 → Ext-L → LEFT nozzle (extruder 1)
+        assert result[0]["extruder_id"] == 1
         assert result[0]["is_external"] is True
 
     def test_external_spool_no_extruder_map(self, scheduler):
@@ -655,3 +708,302 @@ class TestNozzleAwareMapping:
         ]
         result = scheduler._match_filaments_to_slots(required, loaded)
         assert result == [0, 4]
+
+
+# ============================================================================
+# MODEL-SPECIFIC TESTS: Real data from actual printers
+# ============================================================================
+
+
+def _h2d_raw_data():
+    """H2D real data fixture (from live API response 2026-02-18).
+
+    Configuration:
+        LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
+        RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
+        External: 254 (Ext-L, LEFT), 255 (Ext-R, RIGHT, empty)
+
+    ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
+    """
+    return {
+        "ams": [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0, "tray_type": "PETG", "tray_color": "FFFFFFFF", "tray_info_idx": "GFG02"},
+                    {"id": 1, "tray_type": "PLA", "tray_color": "C8C8C8FF", "tray_info_idx": "GFA06"},
+                    {"id": 2, "tray_type": "PETG", "tray_color": "875718FF", "tray_info_idx": "GFG02"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "GFA00"},
+                ],
+            },
+            {
+                "id": 1,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
+                    {"id": 1, "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG02"},
+                    {"id": 2, "tray_type": "PLA", "tray_color": "5F6367FF", "tray_info_idx": "GFA06"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "B39B84FF", "tray_info_idx": "GFA02"},
+                ],
+            },
+            {
+                "id": 128,
+                "tray": [{"id": 0}],  # AMS-HT, empty
+            },
+            {
+                "id": 2,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA-S", "tray_color": "FFFFFFFF", "tray_info_idx": "P8aa1726"},
+                    {"id": 1, "tray_type": "PLA", "tray_color": "56B7E6FF", "tray_info_idx": "PFUS9924"},
+                    {"id": 2, "tray_type": "PETG", "tray_color": "6EE53CFF", "tray_info_idx": "GFG02"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "tray_info_idx": "PFUS9ac9"},
+                ],
+            },
+        ],
+        "vt_tray": [
+            {"id": 254, "tray_type": "PLA", "tray_color": "000000FF", "tray_info_idx": "P4d64437"},
+            {"id": 255, "tray_type": "", "tray_color": "00000000"},  # empty
+        ],
+        "ams_extruder_map": {"0": 1, "1": 0, "2": 1, "128": 0},
+    }
+
+
+def _x1c_raw_data():
+    """X1C real data fixture (from live API response 2026-02-18).
+
+    Configuration:
+        Single nozzle (extruder 0): AMS 0 (4-slot, all empty), AMS 1 (4-slot, 3 loaded)
+        External: 254 (single, empty)
+
+    ams_extruder_map: {"0": 0, "1": 0}  ← NOT empty, all on extruder 0
+    """
+    return {
+        "ams": [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0},  # empty
+                    {"id": 1},  # empty
+                    {"id": 2},  # empty
+                    {"id": 3},  # empty
+                ],
+            },
+            {
+                "id": 1,
+                "tray": [
+                    {"id": 0},  # empty
+                    {"id": 1, "tray_type": "PLA", "tray_color": "EBCFA6FF", "tray_info_idx": "PFUS22b2"},
+                    {"id": 2, "tray_type": "PLA", "tray_color": "FCECD6FF", "tray_info_idx": "P4d64437"},
+                    {"id": 3, "tray_type": "PLA", "tray_color": "0066FFFF", "tray_info_idx": "P4d64437"},
+                ],
+            },
+        ],
+        "vt_tray": [
+            {"id": 254, "tray_type": "", "tray_color": "00000000"},  # empty
+        ],
+        "ams_extruder_map": {"0": 0, "1": 0},
+    }
+
+
+class TestH2DModel:
+    """H2D-specific tests with real printer data (dual nozzle, AMS-HT)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_h2d(self, scheduler):
+        """H2D: correct extruder_id, global_tray_id, AMS-HT handling."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+
+        # Should have 13 loaded filaments (4 + 4 + 0 + 4 + 1 external)
+        assert len(result) == 13
+
+        # AMS 0 trays → extruder 1 (LEFT)
+        ams0 = [f for f in result if f["ams_id"] == 0]
+        assert len(ams0) == 4
+        assert all(f["extruder_id"] == 1 for f in ams0)
+        assert [f["global_tray_id"] for f in ams0] == [0, 1, 2, 3]
+
+        # AMS 1 trays → extruder 0 (RIGHT)
+        ams1 = [f for f in result if f["ams_id"] == 1]
+        assert len(ams1) == 4
+        assert all(f["extruder_id"] == 0 for f in ams1)
+        assert [f["global_tray_id"] for f in ams1] == [4, 5, 6, 7]
+
+        # AMS-HT 128 → empty, should not appear
+        ams_ht = [f for f in result if f["ams_id"] == 128]
+        assert len(ams_ht) == 0
+
+        # AMS 2 trays → extruder 1 (LEFT)
+        ams2 = [f for f in result if f["ams_id"] == 2]
+        assert len(ams2) == 4
+        assert all(f["extruder_id"] == 1 for f in ams2)
+        assert [f["global_tray_id"] for f in ams2] == [8, 9, 10, 11]
+
+    def test_external_spool_extruder_h2d(self, scheduler):
+        """H2D: Ext-L (254) = LEFT (extruder 1), Ext-R (255) = RIGHT (extruder 0)."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        ext = [f for f in result if f["is_external"]]
+        assert len(ext) == 1  # Only 254 has filament
+        assert ext[0]["global_tray_id"] == 254
+        # Ext-L (254) should be LEFT nozzle (extruder 1)
+        assert ext[0]["extruder_id"] == 1
+
+    def test_match_left_nozzle_only(self, scheduler):
+        """H2D: left-nozzle requirement only matches left-nozzle AMS."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 1},  # LEFT
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Black PLA on LEFT: AMS 0 T4 (global 3)
+        assert result == [3]
+
+    def test_match_right_nozzle_only(self, scheduler):
+        """H2D: right-nozzle requirement only matches right-nozzle AMS."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 0},  # RIGHT
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # White PLA on RIGHT: AMS 1 T1 (global 4)
+        assert result == [4]
+
+    def test_reject_cross_nozzle(self, scheduler):
+        """H2D: hard filter rejects cross-nozzle assignment."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        # PLA-S only exists on AMS 2 T1 (LEFT), require on RIGHT
+        required = [
+            {"slot_id": 1, "type": "PLA-S", "color": "#FFFFFF", "nozzle_id": 0},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [-1]  # No fallback to wrong nozzle
+
+    def test_dual_nozzle_multi_filament(self, scheduler):
+        """H2D: multi-filament print maps to correct nozzles."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PETG", "color": "#FFFFFF", "nozzle_id": 1, "tray_info_idx": "GFG02"},
+            {"slot_id": 2, "type": "PLA", "color": "#FFFFFF", "nozzle_id": 0, "tray_info_idx": "GFA00"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # PETG white on LEFT: AMS 0 T1 (global 0)
+        # PLA white on RIGHT: AMS 1 T1 (global 4)
+        assert result == [0, 4]
+
+    def test_external_spool_matches_on_correct_nozzle(self, scheduler):
+        """H2D: external spool on left nozzle matches left-nozzle requirement."""
+
+        class MockStatus:
+            raw_data = _h2d_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "nozzle_id": 1, "tray_info_idx": "P4d64437"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [254]  # External spool on left nozzle
+
+
+class TestX1CModel:
+    """X1C-specific tests with real printer data (single nozzle, 2x regular AMS)."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_build_loaded_filaments_x1c(self, scheduler):
+        """X1C: all filaments on extruder 0, correct global_tray_id."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+
+        # Only 3 loaded (AMS 1 trays 1-3)
+        assert len(result) == 3
+        # All on extruder 0
+        assert all(f["extruder_id"] == 0 for f in result)
+        # Correct global tray IDs
+        assert [f["global_tray_id"] for f in result] == [5, 6, 7]
+
+    def test_single_nozzle_no_filtering(self, scheduler):
+        """X1C: single-nozzle 3MF has no nozzle_id, all trays available."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#0066FF"},  # No nozzle_id
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Blue PLA → AMS 1 T4 (global 7)
+        assert result == [7]
+
+    def test_tray_info_idx_matching_x1c(self, scheduler):
+        """X1C: tray_info_idx matching works across AMS units."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#EBCFA6", "tray_info_idx": "PFUS22b2"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Unique tray_info_idx → AMS 1 T2 (global 5)
+        assert result == [5]
+
+    def test_non_unique_tray_info_idx_color_match_x1c(self, scheduler):
+        """X1C: non-unique tray_info_idx falls back to color matching."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        # P4d64437 appears in AMS 1 T3 and T4
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FCECD6", "tray_info_idx": "P4d64437"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        # Should pick AMS 1 T3 (global 6, color FCECD6) over T4 (0066FF)
+        assert result == [6]
+
+    def test_multi_filament_x1c(self, scheduler):
+        """X1C: multi-filament print matches freely across AMS units."""
+
+        class MockStatus:
+            raw_data = _x1c_raw_data()
+
+        loaded = scheduler._build_loaded_filaments(MockStatus())
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#EBCFA6"},
+            {"slot_id": 2, "type": "PLA", "color": "#0066FF"},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [5, 7]

+ 184 - 0
backend/tests/unit/test_usage_tracker.py

@@ -14,6 +14,7 @@ import pytest
 from backend.app.services.usage_tracker import (
     PrintSession,
     _active_sessions,
+    _decode_mqtt_mapping,
     _track_from_3mf,
     on_print_complete,
     on_print_start,
@@ -839,6 +840,189 @@ class TestTrackFrom3mf:
         assert results[0]["tray_id"] == 1
 
 
+class TestDecodeMqttMapping:
+    """Tests for _decode_mqtt_mapping() — snow-encoded MQTT mapping to global tray IDs."""
+
+    def test_none_input(self):
+        assert _decode_mqtt_mapping(None) is None
+
+    def test_empty_list(self):
+        assert _decode_mqtt_mapping([]) is None
+
+    def test_all_unmapped(self):
+        """All 65535 values → None (no valid mappings)."""
+        assert _decode_mqtt_mapping([65535, 65535, 65535]) is None
+
+    def test_single_ams_slots(self):
+        """AMS 0 slots: snow values 0-3 → global tray IDs 0-3."""
+        assert _decode_mqtt_mapping([0, 1, 2, 3]) == [0, 1, 2, 3]
+
+    def test_multi_ams_slots(self):
+        """AMS 1 (hw_id=1): snow 256=AMS1-T0, 257=AMS1-T1 → global 4, 5."""
+        assert _decode_mqtt_mapping([256, 257]) == [4, 5]
+
+    def test_ams_ht_slot(self):
+        """AMS-HT (hw_id=128): snow 32768 → global 128."""
+        assert _decode_mqtt_mapping([32768]) == [128]
+
+    def test_external_spool(self):
+        """External spool: ams_hw_id=254, slot=0 → global 254."""
+        # snow = 254 * 256 + 0 = 65024
+        assert _decode_mqtt_mapping([65024]) == [254]
+
+    def test_mixed_with_unmapped(self):
+        """Mix of valid and unmapped (65535) values."""
+        result = _decode_mqtt_mapping([1, 65535, 0])
+        assert result == [1, -1, 0]
+
+    def test_h2c_real_mapping(self):
+        """Real H2C mapping from MQTT logs: [1, 0, 65535*4, 32768]."""
+        mapping = [1, 0, 65535, 65535, 65535, 65535, 32768]
+        result = _decode_mqtt_mapping(mapping)
+        assert result == [1, 0, -1, -1, -1, -1, 128]
+
+    def test_non_int_values_treated_as_unmapped(self):
+        """Non-integer values in the mapping are treated as unmapped."""
+        assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
+
+
+class TestMqttMappingIntegration:
+    """Integration tests: MQTT mapping field used in _track_from_3mf."""
+
+    @pytest.mark.asyncio
+    async def test_h2c_multi_filament_uses_mqtt_mapping(self):
+        """H2C: 3 filaments resolved via MQTT mapping field (no ams_mapping, no queue)."""
+        # AMS0-T1 (White PLA), AMS0-T0 (Black PLA), AMS128-T0 (Red PLA)
+        spool_white = _make_spool(spool_id=1, label_weight=1000)
+        spool_black = _make_spool(spool_id=2, label_weight=1000)
+        spool_red = _make_spool(spool_id=3, label_weight=1000)
+        assign_white = _make_assignment(spool_id=1, ams_id=0, tray_id=1)
+        assign_black = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
+        assign_red = _make_assignment(spool_id=3, ams_id=128, tray_id=0)
+        archive = _make_archive(archive_id=12)
+
+        # db: archive, then 3 pairs of (assignment, spool)
+        # No queue lookup because MQTT mapping is found first
+        db = _mock_db_sequential(
+            [
+                archive,
+                assign_white,
+                spool_white,
+                assign_black,
+                spool_black,
+                assign_red,
+                spool_red,
+            ]
+        )
+
+        # MQTT mapping: slot0→AMS0-T1(1), slot1→AMS0-T0(0), slots2-5→unmapped, slot6→AMS128-T0(32768)
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [1, 0, 65535, 65535, 65535, 65535, 32768]},
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+        )
+
+        # 3MF slots 1, 2, 7 (1-based) → indices 0, 1, 6 in mapping
+        filament_usage = [
+            {"slot_id": 1, "used_g": 21.16, "type": "PLA", "color": "#FFFFFF"},
+            {"slot_id": 2, "used_g": 24.22, "type": "PLA", "color": "#000000"},
+            {"slot_id": 7, "used_g": 18.47, "type": "PLA", "color": "#F72323"},
+        ]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=12,
+                status="completed",
+                print_name="Cube + Cube + Cube",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 3
+
+        # slot_id=1 → mapping[0]=1 → AMS0-T1 (White PLA)
+        assert results[0]["spool_id"] == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 1
+        assert results[0]["weight_used"] == 21.2
+
+        # slot_id=2 → mapping[1]=0 → AMS0-T0 (Black PLA)
+        assert results[1]["spool_id"] == 2
+        assert results[1]["ams_id"] == 0
+        assert results[1]["tray_id"] == 0
+        assert results[1]["weight_used"] == 24.2
+
+        # slot_id=7 → mapping[6]=32768 → AMS128-T0 (Red PLA)
+        assert results[2]["spool_id"] == 3
+        assert results[2]["ams_id"] == 128
+        assert results[2]["tray_id"] == 0
+        assert results[2]["weight_used"] == 18.5
+
+    @pytest.mark.asyncio
+    async def test_print_cmd_mapping_takes_priority_over_mqtt(self):
+        """ams_mapping from print command is used even when MQTT mapping exists."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
+        db = _mock_db_sequential([archive, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [0, 65535]},  # MQTT says slot 0 → AMS0-T0
+            progress=100,
+            layer_num=50,
+            tray_now=255,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+                ams_mapping=[2],  # Print cmd says slot 0 → AMS0-T2 (overrides MQTT)
+            )
+
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 2  # From print_cmd mapping, not MQTT
+
+
 class TestNotificationVariables:
     """Tests for filament_details formatting in notifications."""
 

File diff suppressed because it is too large
+ 146 - 2067
frontend/package-lock.json


+ 0 - 2
frontend/package.json

@@ -32,10 +32,8 @@
     "i18next": "25.6.3",
     "i18next-browser-languagedetector": "^8.2.0",
     "i18next-http-backend": "^3.0.2",
-    "install": "^0.13.0",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
-    "npm": "^11.9.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-i18next": "^16.3.5",

+ 3 - 3
frontend/src/__tests__/components/FileManagerModal.test.tsx

@@ -28,7 +28,7 @@ const mockFiles = [
   {
     name: 'benchy.3mf',
     path: '/benchy.3mf',
-    size: 1024000,
+    size: 1048575,
     is_directory: false,
     mtime: '2024-01-15T10:00:00Z',
   },
@@ -135,8 +135,8 @@ describe('FileManagerModal', () => {
       );
 
       await waitFor(() => {
-        // 1024000 bytes = 1000 KB = ~1.0 MB
-        expect(screen.getByText('1000 KB')).toBeInTheDocument();
+        // 1024000 bytes = 1024.0 KB
+        expect(screen.getByText('1024.0 KB')).toBeInTheDocument();
       });
     });
   });

+ 2 - 2
frontend/src/__tests__/components/PrinterQueueWidget.test.tsx

@@ -77,11 +77,11 @@ describe('PrinterQueueWidget', () => {
       });
     });
 
-    it('shows ASAP for unscheduled items', async () => {
+    it('shows Waiting for unscheduled items', async () => {
       render(<PrinterQueueWidget printerId={1} />);
 
       await waitFor(() => {
-        expect(screen.getByText('ASAP')).toBeInTheDocument();
+        expect(screen.getByText('Waiting')).toBeInTheDocument();
       });
     });
   });

+ 135 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -62,6 +62,7 @@ const existingSpool: InventorySpool = {
   rgba: 'FF0000FF',
   label_weight: 1000,
   core_weight: 250,
+  core_weight_catalog_id: null,
   weight_used: 300,
   slicer_filament: 'GFL99',
   slicer_filament_name: 'Generic PLA',
@@ -183,4 +184,138 @@ describe('SpoolFormModal weightTouched', () => {
     // weight_used MUST be included for new spools (default value 0)
     expect(payload).toHaveProperty('weight_used', 0);
   });
+
+  it('preserves core_weight_catalog_id when editing other fields', async () => {
+    const spoolWithCatalogId: InventorySpool = {
+      ...existingSpool,
+      core_weight_catalog_id: 5,
+    };
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithCatalogId}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Change the note field (unrelated to catalog ID)
+    const noteInputs = screen.getAllByPlaceholderText(/note/i);
+    expect(noteInputs.length).toBeGreaterThan(0);
+    fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
+
+    // Click Save
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect(spoolId).toBe(1);
+    // core_weight_catalog_id MUST be preserved when editing other fields
+    expect(payload).toHaveProperty('core_weight_catalog_id', 5);
+    // Other changes should also be present
+    expect(payload).toHaveProperty('note', 'Updated note');
+  });
+
+  it('includes core_weight_catalog_id when selecting from catalog', async () => {
+    const mockCatalog = [
+      { id: 1, name: 'Generic 250g', weight: 250 },
+      { id: 2, name: 'Bambu Lab 250g', weight: 250 },
+      { id: 3, name: 'Standard 300g', weight: 300 },
+    ];
+
+    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // Wait for catalog to load
+    await waitFor(() => {
+      expect(api.getSpoolCatalog).toHaveBeenCalled();
+    });
+
+    // Click on the empty spool weight field to open dropdown
+    const weightInputs = screen.getAllByPlaceholderText(/search/i);
+    const weightPicker = weightInputs.find(input =>
+      input.getAttribute('placeholder')?.toLowerCase().includes('spool')
+    );
+    expect(weightPicker).toBeTruthy();
+    fireEvent.focus(weightPicker!);
+
+    // Click on "Bambu Lab 250g" option
+    const bambuOption = await screen.findByText('Bambu Lab 250g');
+    fireEvent.click(bambuOption);
+
+    // Click the add spool button
+    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
+    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
+    expect(submitButton).toBeTruthy();
+    fireEvent.click(submitButton!);
+
+    await waitFor(() => {
+      expect(api.createSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
+    // Both weight AND catalog ID should be sent
+    expect(payload).toHaveProperty('core_weight', 250);
+    expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
+  });
+
+  it('displays correct catalog name when duplicates exist', async () => {
+    const spoolWithCatalogId: InventorySpool = {
+      ...existingSpool,
+      core_weight: 250,
+      core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
+    };
+
+    const mockCatalog = [
+      { id: 1, name: 'Generic 250g', weight: 250 },
+      { id: 2, name: 'Bambu Lab 250g', weight: 250 },
+      { id: 3, name: 'Standard 300g', weight: 300 },
+    ];
+
+    vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
+
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolWithCatalogId}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Wait for catalog to load
+    await waitFor(() => {
+      expect(api.getSpoolCatalog).toHaveBeenCalled();
+    });
+
+    // Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
+    await waitFor(() => {
+      const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
+      const bambuFound = weightInputs.some(input =>
+        input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
+      );
+      expect(bambuFound).toBeTruthy();
+    });
+  });
 });

+ 306 - 2
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -430,8 +430,9 @@ describe('computeAmsMapping - nozzle filtering', () => {
     expect(result).toEqual([4]);  // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
   });
 
-  it('falls back to all trays when target nozzle has no trays at all', () => {
+  it('returns -1 when target nozzle has no trays (hard filter)', () => {
     // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
+    // Hard filter: cross-nozzle assignment causes "position of left hotend is abnormal"
     const reqs = {
       filaments: [
         { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
@@ -447,7 +448,7 @@ describe('computeAmsMapping - nozzle filtering', () => {
 
     const result = computeAmsMapping(reqs, status);
 
-    expect(result).toEqual([0]);  // Falls back to unfiltered (right nozzle PLA)
+    expect(result).toEqual([-1]);  // Hard filter: no fallback to wrong nozzle
   });
 
   it('stays restricted when target nozzle has trays but wrong type', () => {
@@ -526,3 +527,306 @@ describe('computeAmsMapping - nozzle filtering', () => {
     expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
   });
 });
+
+// ============================================================================
+// MODEL-SPECIFIC TESTS: Real data from actual printers
+// ============================================================================
+
+/**
+ * H2D real data fixture (from live API response 2026-02-18).
+ *
+ * Configuration:
+ *   LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot)
+ *   RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty)
+ *   External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle)
+ *
+ * ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0}
+ */
+function createH2DStatus(): PrinterStatus {
+  const status = createPrinterStatus(
+    [
+      {
+        id: 0, // LEFT nozzle (extruder 1)
+        humidity: 24,
+        temp: 21.4,
+        tray: [
+          { id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
+          { id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
+        ],
+      },
+      {
+        id: 1, // RIGHT nozzle (extruder 0)
+        humidity: 25,
+        temp: 21.7,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' },
+          { id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' },
+          { id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' },
+        ],
+      },
+      {
+        id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty
+        humidity: 48,
+        temp: 21.4,
+        tray: [
+          { id: 0 }, // empty tray
+        ],
+      },
+      {
+        id: 2, // LEFT nozzle (extruder 1)
+        humidity: 18,
+        temp: 24.0,
+        tray: [
+          { id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' },
+          { id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' },
+          { id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' },
+          { id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' },
+        ],
+      },
+    ],
+    [
+      { id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded)
+      { id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty)
+    ]
+  );
+  (status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 };
+  return status;
+}
+
+/**
+ * X1C real data fixture (from live API response 2026-02-18).
+ *
+ * Configuration:
+ *   Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot)
+ *   External: 254 (single)
+ *
+ * ams_extruder_map: {"0": 0, "1": 0}  ← NOT empty, all on extruder 0
+ */
+function createX1CStatus(): PrinterStatus {
+  const status = createPrinterStatus(
+    [
+      {
+        id: 0,
+        humidity: 23,
+        temp: 26.1,
+        tray: [
+          { id: 0 }, // empty (has tray_color but no tray_type)
+          { id: 1 }, // empty
+          { id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type)
+          { id: 3 }, // empty
+        ],
+      },
+      {
+        id: 1,
+        humidity: 20,
+        temp: 25.9,
+        tray: [
+          { id: 0 }, // empty
+          { id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' },
+          { id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' },
+          { id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' },
+        ],
+      },
+    ],
+    [
+      { id: 254, tray_type: '', tray_color: '00000000' }, // empty
+    ]
+  );
+  (status as any).ams_extruder_map = { '0': 0, '1': 0 };
+  return status;
+}
+
+describe('H2D model tests (dual nozzle, real data)', () => {
+  describe('buildLoadedFilaments', () => {
+    it('assigns correct extruderId to all AMS units', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+
+      // AMS 0 trays → extruder 1 (LEFT)
+      const ams0 = result.filter((f) => f.amsId === 0);
+      expect(ams0).toHaveLength(4);
+      ams0.forEach((f) => expect(f.extruderId).toBe(1));
+
+      // AMS 1 trays → extruder 0 (RIGHT)
+      const ams1 = result.filter((f) => f.amsId === 1);
+      expect(ams1).toHaveLength(4);
+      ams1.forEach((f) => expect(f.extruderId).toBe(0));
+
+      // AMS 2 trays → extruder 1 (LEFT)
+      const ams2 = result.filter((f) => f.amsId === 2);
+      expect(ams2).toHaveLength(4);
+      ams2.forEach((f) => expect(f.extruderId).toBe(1));
+    });
+
+    it('computes correct globalTrayId for all AMS types', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+
+      // Regular AMS: amsId * 4 + trayId
+      expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0);
+      expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3);
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4);
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
+      expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8);
+      expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11);
+    });
+
+    it('skips empty AMS-HT tray (no tray_type)', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      // AMS-HT 128 is empty in real data — should be skipped
+      const ht = result.filter((f) => f.amsId === 128);
+      expect(ht).toHaveLength(0);
+    });
+
+    it('includes loaded external spool with correct extruder', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      const ext = result.filter((f) => f.isExternal);
+      // Only Ext-L (254) has filament, Ext-R (255) is empty
+      expect(ext).toHaveLength(1);
+      expect(ext[0].globalTrayId).toBe(254);
+      expect(ext[0].type).toBe('PLA');
+      // Ext-L (254) should be LEFT nozzle (extruder 1)
+      expect(ext[0].extruderId).toBe(1);
+    });
+
+    it('returns 13 loaded filaments total (12 AMS + 1 external)', () => {
+      const result = buildLoadedFilaments(createH2DStatus());
+      // AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1
+      expect(result).toHaveLength(13);
+    });
+  });
+
+  describe('computeAmsMapping', () => {
+    it('matches left-nozzle filament to left-nozzle AMS only', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      // Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left
+      expect(result).toEqual([3]);
+    });
+
+    it('matches right-nozzle filament to right-nozzle AMS only', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      // White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right
+      expect(result).toEqual([4]);
+    });
+
+    it('rejects cross-nozzle assignment (right requires type only on left)', () => {
+      const reqs = {
+        filaments: [
+          // PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle
+          { slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([-1]); // No fallback to wrong nozzle
+    });
+
+    it('maps dual-nozzle multi-filament print correctly', () => {
+      const reqs = {
+        filaments: [
+          // Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0)
+          { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' },
+          // Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4)
+          { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([0, 4]);
+    });
+
+    it('matches external spool on correct nozzle', () => {
+      const reqs = {
+        filaments: [
+          // Ext-L has black PLA loaded, on LEFT nozzle (extruder 1)
+          { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createH2DStatus());
+      expect(result).toEqual([254]); // External spool on left nozzle
+    });
+  });
+});
+
+describe('X1C model tests (single nozzle, real data)', () => {
+  describe('buildLoadedFilaments', () => {
+    it('assigns all filaments to extruder 0', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      result.forEach((f) => expect(f.extruderId).toBe(0));
+    });
+
+    it('computes correct globalTrayId for regular AMS', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      // AMS 1 T2 (tray id 1) → globalTrayId 5
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5);
+      // AMS 1 T3 (tray id 2) → globalTrayId 6
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6);
+      // AMS 1 T4 (tray id 3) → globalTrayId 7
+      expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7);
+    });
+
+    it('returns only loaded trays (3 from AMS 1)', () => {
+      const result = buildLoadedFilaments(createX1CStatus());
+      // AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty
+      expect(result).toHaveLength(3);
+    });
+  });
+
+  describe('computeAmsMapping', () => {
+    it('matches single-nozzle file without nozzle filtering', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF)
+      expect(result).toEqual([7]);
+    });
+
+    it('matches by tray_info_idx across AMS units', () => {
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5)
+      expect(result).toEqual([5]);
+    });
+
+    it('handles non-unique tray_info_idx with color matching', () => {
+      // P4d64437 appears in both AMS 1 T3 and T4
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF)
+      expect(result).toEqual([6]);
+    });
+
+    it('does not cross-nozzle filter for single-nozzle printer', () => {
+      // Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id
+      const reqs = {
+        filaments: [
+          { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 },
+          { slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 },
+        ],
+      };
+      const result = computeAmsMapping(reqs, createX1CStatus());
+      // Both should match freely across all AMS units
+      expect(result).toEqual([5, 7]);
+    });
+  });
+});

+ 437 - 0
frontend/src/__tests__/utils/date.test.ts

@@ -0,0 +1,437 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+  getDatePlaceholder,
+  getTimePlaceholder,
+  formatDateInput,
+  formatTimeInput,
+  parseDateInput,
+  parseTimeInput,
+  toDateTimeLocalValue,
+  applyTimeFormat,
+  parseUTCDate,
+  formatDate,
+  formatDateOnly,
+  formatDateTime,
+  formatTimeOnly,
+  formatETA,
+  formatDuration,
+  formatRelativeTime,
+} from '../../utils/date';
+
+describe('getDatePlaceholder', () => {
+  it('returns MM/DD/YYYY for us format', () => {
+    expect(getDatePlaceholder('us')).toBe('MM/DD/YYYY');
+  });
+
+  it('returns DD/MM/YYYY for eu format', () => {
+    expect(getDatePlaceholder('eu')).toBe('DD/MM/YYYY');
+  });
+
+  it('returns YYYY-MM-DD for iso format', () => {
+    expect(getDatePlaceholder('iso')).toBe('YYYY-MM-DD');
+  });
+
+  it('returns a placeholder for system format', () => {
+    const result = getDatePlaceholder('system');
+    expect(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']).toContain(result);
+  });
+});
+
+describe('getTimePlaceholder', () => {
+  it('returns HH:MM AM/PM for 12h format', () => {
+    expect(getTimePlaceholder('12h')).toBe('HH:MM AM/PM');
+  });
+
+  it('returns HH:MM for 24h format', () => {
+    expect(getTimePlaceholder('24h')).toBe('HH:MM');
+  });
+
+  it('returns a placeholder for system format', () => {
+    const result = getTimePlaceholder('system');
+    expect(['HH:MM AM/PM', 'HH:MM']).toContain(result);
+  });
+});
+
+describe('formatDateInput', () => {
+  const date = new Date(2025, 5, 15); // June 15, 2025
+
+  it('formats as MM/DD/YYYY for us format', () => {
+    expect(formatDateInput(date, 'us')).toBe('06/15/2025');
+  });
+
+  it('formats as DD/MM/YYYY for eu format', () => {
+    expect(formatDateInput(date, 'eu')).toBe('15/06/2025');
+  });
+
+  it('formats as YYYY-MM-DD for iso format', () => {
+    expect(formatDateInput(date, 'iso')).toBe('2025-06-15');
+  });
+
+  it('uses toLocaleDateString for system format', () => {
+    const result = formatDateInput(date, 'system');
+    expect(result).toBeTruthy();
+  });
+});
+
+describe('formatTimeInput', () => {
+  it('formats as 12h with AM', () => {
+    const date = new Date(2025, 0, 1, 9, 30);
+    expect(formatTimeInput(date, '12h')).toBe('9:30 AM');
+  });
+
+  it('formats as 12h with PM', () => {
+    const date = new Date(2025, 0, 1, 14, 45);
+    expect(formatTimeInput(date, '12h')).toBe('2:45 PM');
+  });
+
+  it('formats 12:00 as 12:00 PM', () => {
+    const date = new Date(2025, 0, 1, 12, 0);
+    expect(formatTimeInput(date, '12h')).toBe('12:00 PM');
+  });
+
+  it('formats 00:00 as 12:00 AM', () => {
+    const date = new Date(2025, 0, 1, 0, 0);
+    expect(formatTimeInput(date, '12h')).toBe('12:00 AM');
+  });
+
+  it('formats as 24h', () => {
+    const date = new Date(2025, 0, 1, 14, 30);
+    expect(formatTimeInput(date, '24h')).toBe('14:30');
+  });
+
+  it('pads hours in 24h format', () => {
+    const date = new Date(2025, 0, 1, 9, 5);
+    expect(formatTimeInput(date, '24h')).toBe('09:05');
+  });
+});
+
+describe('parseDateInput', () => {
+  it('parses us format MM/DD/YYYY', () => {
+    const result = parseDateInput('06/15/2025', 'us');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5); // June
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('parses eu format DD/MM/YYYY', () => {
+    const result = parseDateInput('15/06/2025', 'eu');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5);
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('parses iso format YYYY-MM-DD', () => {
+    const result = parseDateInput('2025-06-15', 'iso');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5);
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('accepts different separators', () => {
+    expect(parseDateInput('06-15-2025', 'us')?.getDate()).toBe(15);
+    expect(parseDateInput('15.06.2025', 'eu')?.getDate()).toBe(15);
+    expect(parseDateInput('2025/06/15', 'iso')?.getDate()).toBe(15);
+  });
+
+  it('returns null for invalid input', () => {
+    expect(parseDateInput('', 'us')).toBeNull();
+    expect(parseDateInput('invalid', 'us')).toBeNull();
+    expect(parseDateInput('13/32/2025', 'us')).toBeNull(); // invalid month
+    expect(parseDateInput('01/01/1800', 'us')).toBeNull(); // year out of range
+  });
+
+  it('returns null for invalid month', () => {
+    expect(parseDateInput('13/01/2025', 'us')).toBeNull();
+    expect(parseDateInput('00/01/2025', 'us')).toBeNull();
+  });
+
+  it('returns null for invalid day', () => {
+    expect(parseDateInput('01/32/2025', 'us')).toBeNull();
+    expect(parseDateInput('01/00/2025', 'us')).toBeNull();
+  });
+});
+
+describe('parseTimeInput', () => {
+  it('parses 24h format', () => {
+    expect(parseTimeInput('14:30')).toEqual({ hours: 14, minutes: 30 });
+    expect(parseTimeInput('09:05')).toEqual({ hours: 9, minutes: 5 });
+    expect(parseTimeInput('0:00')).toEqual({ hours: 0, minutes: 0 });
+  });
+
+  it('parses 12h format with AM', () => {
+    expect(parseTimeInput('9:30 AM')).toEqual({ hours: 9, minutes: 30 });
+    expect(parseTimeInput('12:00 AM')).toEqual({ hours: 0, minutes: 0 });
+  });
+
+  it('parses 12h format with PM', () => {
+    expect(parseTimeInput('2:45 PM')).toEqual({ hours: 14, minutes: 45 });
+    expect(parseTimeInput('12:00 PM')).toEqual({ hours: 12, minutes: 0 });
+  });
+
+  it('is case insensitive for AM/PM', () => {
+    expect(parseTimeInput('9:30 am')).toEqual({ hours: 9, minutes: 30 });
+    expect(parseTimeInput('2:45 pm')).toEqual({ hours: 14, minutes: 45 });
+  });
+
+  it('returns null for invalid input', () => {
+    expect(parseTimeInput('')).toBeNull();
+    expect(parseTimeInput('invalid')).toBeNull();
+    expect(parseTimeInput('25:00')).toBeNull();
+    expect(parseTimeInput('12:60')).toBeNull();
+    expect(parseTimeInput('-1:00')).toBeNull();
+  });
+});
+
+describe('toDateTimeLocalValue', () => {
+  it('formats date to datetime-local value', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    expect(toDateTimeLocalValue(date)).toBe('2025-06-15T14:30');
+  });
+
+  it('pads single digit values', () => {
+    const date = new Date(2025, 0, 5, 9, 5);
+    expect(toDateTimeLocalValue(date)).toBe('2025-01-05T09:05');
+  });
+});
+
+describe('applyTimeFormat', () => {
+  it('sets hour12 true for 12h format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, '12h');
+    expect(options.hour12).toBe(true);
+  });
+
+  it('sets hour12 false for 24h format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, '24h');
+    expect(options.hour12).toBe(false);
+  });
+
+  it('leaves hour12 undefined for system format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, 'system');
+    expect(options.hour12).toBeUndefined();
+  });
+
+  it('returns the modified options object', () => {
+    const options: Intl.DateTimeFormatOptions = { hour: '2-digit' };
+    const result = applyTimeFormat(options, '12h');
+    expect(result).toBe(options);
+    expect(result.hour).toBe('2-digit');
+  });
+});
+
+describe('parseUTCDate', () => {
+  it('returns null for null/undefined input', () => {
+    expect(parseUTCDate(null)).toBeNull();
+    expect(parseUTCDate(undefined)).toBeNull();
+    expect(parseUTCDate('')).toBeNull();
+  });
+
+  it('parses ISO string with Z suffix as-is', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00Z');
+    expect(result).toBeInstanceOf(Date);
+    expect(result?.getUTCHours()).toBe(12);
+  });
+
+  it('parses ISO string with timezone offset as-is', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00+05:00');
+    expect(result).toBeInstanceOf(Date);
+  });
+
+  it('appends Z to strings without timezone indicator', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00');
+    expect(result).toBeInstanceOf(Date);
+    expect(result?.getUTCHours()).toBe(12);
+  });
+});
+
+describe('formatDate', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDate(null)).toBe('');
+    expect(formatDate(undefined)).toBe('');
+  });
+
+  it('formats a valid date string', () => {
+    const result = formatDate('2025-06-15T12:00:00Z');
+    expect(result).toBeTruthy();
+    expect(result).toContain('2025');
+  });
+
+  it('accepts custom options', () => {
+    const result = formatDate('2025-06-15T12:00:00Z', { year: 'numeric' });
+    expect(result).toContain('2025');
+  });
+});
+
+describe('formatDateOnly', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDateOnly(null)).toBe('');
+  });
+
+  it('formats date without time', () => {
+    const result = formatDateOnly('2025-06-15T12:00:00Z');
+    expect(result).toBeTruthy();
+    expect(result).toContain('2025');
+  });
+});
+
+describe('formatDateTime', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDateTime(null)).toBe('');
+  });
+
+  it('formats with 12h time format', () => {
+    const result = formatDateTime('2025-06-15T14:00:00Z', '12h');
+    expect(result).toBeTruthy();
+  });
+
+  it('formats with 24h time format', () => {
+    const result = formatDateTime('2025-06-15T14:00:00Z', '24h');
+    expect(result).toBeTruthy();
+  });
+});
+
+describe('formatTimeOnly', () => {
+  it('formats time with 12h format', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    const result = formatTimeOnly(date, '12h');
+    expect(result).toMatch(/2:30|02:30/);
+    expect(result.toUpperCase()).toContain('PM');
+  });
+
+  it('formats time with 24h format', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    const result = formatTimeOnly(date, '24h');
+    expect(result).toContain('14:30');
+  });
+});
+
+describe('formatETA', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('returns time only for same day', () => {
+    const result = formatETA(60); // 1 hour from now
+    expect(result).toBeTruthy();
+  });
+
+  it('includes "Tomorrow" for next day', () => {
+    const result = formatETA(60 * 24); // 24 hours from now
+    expect(result).toContain('Tomorrow');
+  });
+
+  it('uses translation function for tomorrow', () => {
+    const t = vi.fn((key: string) => (key === 'common.tomorrow' ? 'Demain' : key));
+    const result = formatETA(60 * 24, 'system', t);
+    expect(result).toContain('Demain');
+  });
+
+  it('shows weekday for dates beyond tomorrow', () => {
+    const result = formatETA(60 * 48); // 48 hours from now
+    expect(result).not.toContain('Tomorrow');
+  });
+});
+
+describe('formatDuration', () => {
+  it('returns "--" for null/undefined', () => {
+    expect(formatDuration(null)).toBe('--');
+    expect(formatDuration(undefined)).toBe('--');
+  });
+
+  it('returns "--" for negative values', () => {
+    expect(formatDuration(-1)).toBe('--');
+  });
+
+  it('formats minutes only when under 1 hour', () => {
+    expect(formatDuration(0)).toBe('0m');
+    expect(formatDuration(60)).toBe('1m');
+    expect(formatDuration(2700)).toBe('45m');
+  });
+
+  it('formats hours and minutes', () => {
+    expect(formatDuration(3600)).toBe('1h 0m');
+    expect(formatDuration(5400)).toBe('1h 30m');
+    expect(formatDuration(9000)).toBe('2h 30m');
+  });
+});
+
+describe('formatRelativeTime', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('returns "-" for null input', () => {
+    expect(formatRelativeTime(null)).toBe('-');
+  });
+
+  it('returns translated unknown for null with translation', () => {
+    const t = vi.fn((key: string) => (key === 'time.unknown' ? 'Unknown' : key));
+    expect(formatRelativeTime(null, 'system', t)).toBe('Unknown');
+  });
+
+  it('returns "Just now" for times less than 1 minute ago', () => {
+    expect(formatRelativeTime('2025-06-15T11:59:30Z')).toBe('Just now');
+  });
+
+  it('returns "Now" for times less than 1 minute in future', () => {
+    expect(formatRelativeTime('2025-06-15T12:00:30Z')).toBe('Now');
+  });
+
+  it('returns minutes ago for times under 1 hour ago', () => {
+    expect(formatRelativeTime('2025-06-15T11:55:00Z')).toBe('5m ago');
+    expect(formatRelativeTime('2025-06-15T11:30:00Z')).toBe('30m ago');
+  });
+
+  it('returns "in Xm" for times under 1 hour in future', () => {
+    expect(formatRelativeTime('2025-06-15T12:05:00Z')).toBe('in 5m');
+    expect(formatRelativeTime('2025-06-15T12:30:00Z')).toBe('in 30m');
+  });
+
+  it('returns hours ago for times under 1 day ago', () => {
+    expect(formatRelativeTime('2025-06-15T10:00:00Z')).toBe('2h ago');
+    expect(formatRelativeTime('2025-06-15T06:00:00Z')).toBe('6h ago');
+  });
+
+  it('returns "in Xh" for times under 1 day in future', () => {
+    expect(formatRelativeTime('2025-06-15T14:00:00Z')).toBe('in 2h');
+    expect(formatRelativeTime('2025-06-15T18:00:00Z')).toBe('in 6h');
+  });
+
+  it('returns days ago for times under 7 days ago', () => {
+    expect(formatRelativeTime('2025-06-14T12:00:00Z')).toBe('1d ago');
+    expect(formatRelativeTime('2025-06-10T12:00:00Z')).toBe('5d ago');
+  });
+
+  it('returns "in Xd" for times under 7 days in future', () => {
+    expect(formatRelativeTime('2025-06-16T12:00:00Z')).toBe('in 1d');
+    expect(formatRelativeTime('2025-06-20T12:00:00Z')).toBe('in 5d');
+  });
+
+  it('returns formatted date for times older than 7 days', () => {
+    const result = formatRelativeTime('2025-06-01T12:00:00Z');
+    expect(result).toContain('2025');
+  });
+
+  it('uses translation function when provided', () => {
+    const t = vi.fn((key: string, options?: Record<string, unknown>) => {
+      if (key === 'time.minsAgo') return `${options?.count} minutes ago`;
+      if (key === 'time.inMins') return `in ${options?.count} minutes`;
+      return key;
+    });
+
+    expect(formatRelativeTime('2025-06-15T11:55:00Z', 'system', t)).toBe('5 minutes ago');
+    expect(formatRelativeTime('2025-06-15T12:05:00Z', 'system', t)).toBe('in 5 minutes');
+  });
+});

+ 43 - 0
frontend/src/__tests__/utils/file.test.ts

@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { formatFileSize } from '../../utils/file';
+
+describe('formatFileSize', () => {
+  it('returns "0 B" for 0 bytes', () => {
+    expect(formatFileSize(0)).toBe('0 B');
+  });
+
+  it('returns bytes without decimals for values under 1 KB', () => {
+    expect(formatFileSize(1)).toBe('1 B');
+    expect(formatFileSize(500)).toBe('500 B');
+    expect(formatFileSize(1023)).toBe('1023 B');
+  });
+
+  it('returns KB with 1 decimal for values under 1 MB', () => {
+    expect(formatFileSize(1024)).toBe('1.0 KB');
+    expect(formatFileSize(1536)).toBe('1.5 KB');
+    expect(formatFileSize(10240)).toBe('10.0 KB');
+  });
+
+  it('returns MB with 1 decimal for values under 1 GB', () => {
+    expect(formatFileSize(1048576)).toBe('1.0 MB');
+    expect(formatFileSize(1572864)).toBe('1.5 MB');
+    expect(formatFileSize(10485760)).toBe('10.0 MB');
+  });
+
+  it('returns GB with 1 decimal for values under 1 TB', () => {
+    expect(formatFileSize(1073741824)).toBe('1.0 GB');
+    expect(formatFileSize(1610612736)).toBe('1.5 GB');
+  });
+
+  it('returns TB with 1 decimal for very large values', () => {
+    expect(formatFileSize(1099511627776)).toBe('1.0 TB');
+    expect(formatFileSize(1649267441664)).toBe('1.5 TB');
+  });
+
+  it('handles edge cases at unit boundaries', () => {
+    expect(formatFileSize(1023)).toBe('1023 B');
+    expect(formatFileSize(1024)).toBe('1.0 KB');
+    expect(formatFileSize(1048575)).toBe('1024.0 KB');
+    expect(formatFileSize(1048576)).toBe('1.0 MB');
+  });
+});

+ 17 - 4
frontend/src/api/client.ts

@@ -1771,6 +1771,7 @@ export interface InventorySpool {
   brand: string | null;
   label_weight: number;
   core_weight: number;
+  core_weight_catalog_id: number | null;
   weight_used: number;
   slicer_filament: string | null;
   slicer_filament_name: string | null;
@@ -2785,6 +2786,10 @@ export const api = {
   },
   getSource3mfForSlicer: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  createSourceSlicerToken: (archiveId: number) =>
+    request<{ token: string }>(`/archives/${archiveId}/source-slicer-token`, { method: 'POST' }),
+  getSourceSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/source-dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -2907,6 +2912,10 @@ export const api = {
     `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  createArchiveSlicerToken: (archiveId: number) =>
+    request<{ token: string }>(`/archives/${archiveId}/slicer-token`, { method: 'POST' }),
+  getArchiveSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   getArchivePlates: (archiveId: number) =>
     request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
@@ -3312,10 +3321,10 @@ export const api = {
       { method: 'POST' }
     ),
 
-  // Filaments
-  listFilaments: () => request<Filament[]>('/filaments/'),
-  getFilament: (id: number) => request<Filament>(`/filaments/${id}`),
-  getFilamentsByType: (type: string) => request<Filament[]>(`/filaments/by-type/${type}`),
+  // Filament Catalog (material types with cost/temp data)
+  listFilaments: () => request<Filament[]>('/filament-catalog/'),
+  getFilament: (id: number) => request<Filament>(`/filament-catalog/${id}`),
+  getFilamentsByType: (type: string) => request<Filament[]>(`/filament-catalog/by-type/${type}`),
 
   // Notification Providers
   getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
@@ -3937,6 +3946,10 @@ export const api = {
   deleteLibraryFile: (id: number) =>
     request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
+  createLibrarySlicerToken: (fileId: number) =>
+    request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
+  getLibrarySlicerDownloadUrl: (fileId: number, token: string, filename: string) =>
+    `${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(filename)}`,
   downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
     const headers: Record<string, string> = {};
     if (authToken) {

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

@@ -177,7 +177,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                         <p className="text-xs text-bambu-gray">
                           {spool.color_name || ''}
                           {spool.label_weight ? ` - ${spool.label_weight}g` : ''}
-                          {spool.weight_used > 0 ? ` (${Math.round(spool.weight_used)}g used)` : ''}
+                          {spool.label_weight ? ` (${Math.max(0, Math.round(spool.label_weight - spool.weight_used))}g ${t('ams.remainingUnit')})` : ''}
                         </p>
                       </div>
                       {selectedSpoolId === spool.id && (

+ 6 - 2
frontend/src/components/FilamentHoverCard.tsx

@@ -24,7 +24,7 @@ interface SpoolmanConfig {
 interface InventoryConfig {
   onAssignSpool?: () => void;
   onUnassignSpool?: () => void;
-  assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null } | null;
+  assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null; remainingWeightGrams?: number | null } | null;
 }
 
 interface ConfigureSlotConfig {
@@ -148,6 +148,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   };
 
   const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
+  const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
 
   return (
     <div
@@ -238,7 +239,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     {t('ams.fill')}
                   </span>
                   <span className="text-xs text-white font-semibold flex items-center gap-1">
-                    {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
+                    <span>{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}</span>
+                    {assignedRemainingWeight !== null && data.fillLevel !== null && (
+                      <span className="text-[9px] text-bambu-gray font-normal">• {assignedRemainingWeight}g</span>
+                    )}
                     {data.fillSource === 'spoolman' && data.fillLevel !== null && (
                       <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
                     )}

+ 2 - 8
frontend/src/components/FileManagerModal.tsx

@@ -29,6 +29,7 @@ import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import type { PlateMetadata } from '../types/plates';
 import { useToast } from '../contexts/ToastContext';
+import { formatFileSize } from '../utils/file';
 
 interface FileManagerModalProps {
   printerId: number;
@@ -235,14 +236,6 @@ function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: Prin
   );
 }
 
-function formatFileSize(bytes: number): string {
-  if (bytes === 0) return '0 B';
-  const k = 1024;
-  const sizes = ['B', 'KB', 'MB', 'GB'];
-  const i = Math.floor(Math.log(bytes) / Math.log(k));
-  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
-}
-
 function formatStorageSize(bytes: number): string {
   if (bytes === 0) return '0 GB';
   const gb = bytes / (1024 * 1024 * 1024);
@@ -253,6 +246,7 @@ function formatStorageSize(bytes: number): string {
   return `${mb.toFixed(0)} MB`;
 }
 
+
 function getFileIcon(filename: string, isDirectory: boolean) {
   if (isDirectory) return Folder;
 

+ 3 - 25
frontend/src/components/GitHubBackupSettings.tsx

@@ -35,6 +35,7 @@ import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
+import { formatRelativeTime } from '../utils/date';
 
 interface StatusBadgeProps {
   status: string | null;
@@ -71,29 +72,6 @@ function formatDateTime(dateStr: string | null): string {
   return date.toLocaleString();
 }
 
-function formatRelativeTime(dateStr: string | null): string {
-  if (!dateStr) return '-';
-  const date = new Date(dateStr);
-  const now = new Date();
-  const diffMs = date.getTime() - now.getTime();
-  const diffMins = Math.round(diffMs / 60000);
-
-  if (diffMins < 0) {
-    const absMins = Math.abs(diffMins);
-    if (absMins < 60) return `${absMins}m ago`;
-    const hours = Math.floor(absMins / 60);
-    if (hours < 24) return `${hours}h ago`;
-    const days = Math.floor(hours / 24);
-    return `${days}d ago`;
-  } else {
-    if (diffMins < 60) return `in ${diffMins}m`;
-    const hours = Math.floor(diffMins / 60);
-    if (hours < 24) return `in ${hours}h`;
-    const days = Math.floor(hours / 24);
-    return `in ${days}d`;
-  }
-}
-
 export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -563,7 +541,7 @@ export function GitHubBackupSettings() {
                   <div className="flex items-center gap-2 text-bambu-gray">
                     {status.last_backup_at ? (
                       <>
-                        <span>Last backup: {formatRelativeTime(status.last_backup_at)}</span>
+                        <span>Last backup: {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
                         <StatusBadge status={status.last_backup_status} />
                       </>
                     ) : (
@@ -573,7 +551,7 @@ export function GitHubBackupSettings() {
                   {status.next_scheduled_run && (
                     <span className="text-bambu-gray">
                       <Clock className="w-3 h-3 inline mr-1" />
-                      Next: {formatRelativeTime(status.next_scheduled_run)}
+                      Next: {formatRelativeTime(status.next_scheduled_run, 'system', t)}
                     </span>
                   )}
                 </div>

+ 20 - 8
frontend/src/components/ModelViewerModal.tsx

@@ -263,17 +263,29 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
 
   const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
 
-  const handleOpenInSlicer = () => {
+  const handleOpenInSlicer = async () => {
     if (!canOpenInSlicer) return;
-    // URL must include .3mf filename for Bambu Studio to recognize the format
     const filename = title || 'model';
-    if (isLibrary) {
-      const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-      openInSlicer(downloadUrl, preferredSlicer);
-      return;
+    try {
+      if (isLibrary) {
+        const { token } = await api.createLibrarySlicerToken(libraryFileId!);
+        const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
+        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+      } else {
+        const { token } = await api.createArchiveSlicerToken(archiveId!);
+        const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
+        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+      }
+    } catch {
+      // Fallback to direct URL (works when auth is disabled)
+      if (isLibrary) {
+        const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
+        openInSlicer(downloadUrl, preferredSlicer);
+      } else {
+        const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
+        openInSlicer(downloadUrl, preferredSlicer);
+      }
     }
-    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-    openInSlicer(downloadUrl, preferredSlicer);
   };
 
   return (

+ 1 - 6
frontend/src/components/PendingUploadsPanel.tsx

@@ -8,12 +8,7 @@ import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { ConfirmModal } from './ConfirmModal';
-
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
+import { formatFileSize } from '../utils/file';
 
 function formatTimeAgo(dateStr: string): string {
   const date = new Date(dateStr);

+ 3 - 1
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -159,7 +159,9 @@ export function FilamentMapping({
                 <option value="" className="bg-bambu-dark text-bambu-gray">
                   -- Select slot --
                 </option>
-                {loadedFilaments.map((f) => (
+                {loadedFilaments
+                  .filter((f) => item.nozzle_id == null || f.extruderId === item.nozzle_id)
+                  .map((f) => (
                   <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
                     {f.label}: {f.type} ({f.colorName})
                   </option>

+ 2 - 16
frontend/src/components/PrinterQueueWidget.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
-import { parseUTCDate } from '../utils/date';
+import { formatRelativeTime } from '../utils/date';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
@@ -13,20 +13,6 @@ interface PrinterQueueWidgetProps {
   plateCleared?: boolean;
 }
 
-function formatRelativeTime(dateString: string | null): string {
-  if (!dateString) return 'ASAP';
-  const date = parseUTCDate(dateString);
-  if (!date) return 'ASAP';
-  const now = new Date();
-  const diff = date.getTime() - now.getTime();
-
-  if (diff < 0) return 'Now';
-  if (diff < 60000) return 'In <1 min';
-  if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
-  if (diff < 86400000) return `In ${Math.round(diff / 3600000)}h`;
-  return date.toLocaleDateString();
-}
-
 export function PrinterQueueWidget({ printerId, printerState, plateCleared }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -117,7 +103,7 @@ export function PrinterQueueWidget({ printerId, printerState, plateCleared }: Pr
         <div className="flex items-center gap-2 flex-shrink-0">
           <span className="text-xs text-bambu-gray flex items-center gap-1">
             <Clock className="w-3 h-3" />
-            {formatRelativeTime(nextItem?.scheduled_time || null)}
+            {nextItem?.scheduled_time ? formatRelativeTime(nextItem.scheduled_time, 'system', t) : t('time.waiting')}
           </span>
           {totalPending > 1 && (
             <span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded">

+ 9 - 1
frontend/src/components/SpoolFormModal.tsx

@@ -171,6 +171,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
           rgba: spool.rgba || '808080FF',
           label_weight: spool.label_weight || 1000,
           core_weight: spool.core_weight || 250,
+          core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
           weight_used: spool.weight_used || 0,
           slicer_filament: spool.slicer_filament || '',
           note: spool.note || '',
@@ -319,7 +320,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     if (!validation.isValid) {
       setErrors(validation.errors);
       // Switch to filament tab if there are errors there
-      if (validation.errors.slicer_filament || validation.errors.material) {
+      if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
         setActiveTab('filament');
       }
       return;
@@ -336,6 +337,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
       rgba: formData.rgba || null,
       label_weight: formData.label_weight,
       core_weight: formData.core_weight,
+      core_weight_catalog_id: formData.core_weight_catalog_id,
       slicer_filament: formData.slicer_filament || null,
       slicer_filament_name: presetName,
       nozzle_temp_min: null,
@@ -436,6 +438,12 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
                 {errors.material && (
                   <p className="mt-1 text-xs text-red-400">{errors.material}</p>
                 )}
+                {errors.brand && (
+                  <p className="mt-1 text-xs text-red-400">{errors.brand}</p>
+                )}
+                {errors.subtype && (
+                  <p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
+                )}
               </div>
 
               {/* Color Section */}

+ 55 - 9
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -8,15 +8,18 @@ function SpoolWeightPicker({
   catalog,
   value,
   onChange,
+  catalogId,
+  onCatalogIdChange,
 }: {
   catalog: { id: number; name: string; weight: number }[];
   value: number;
   onChange: (weight: number) => void;
+  catalogId: number | null;
+  onCatalogIdChange: (id: number | null) => void;
 }) {
   const { t } = useTranslation();
   const [isOpen, setIsOpen] = useState(false);
   const [search, setSearch] = useState('');
-  const [selectedId, setSelectedId] = useState<number | null>(null);
   const dropdownRef = useRef<HTMLDivElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
 
@@ -30,6 +33,35 @@ function SpoolWeightPicker({
     return () => document.removeEventListener('mousedown', handleClick);
   }, []);
 
+  // When value changes, auto-select if there's only one matching entry or keep selection if it still matches
+  useEffect(() => {
+    // If no catalog loaded yet, skip matching logic
+    if (catalog.length === 0) {
+      return;
+    }
+
+    const matches = catalog.filter(e => e.weight === value);
+
+    // If currently selected entry still matches the weight, keep it selected
+    if (catalogId) {
+      const selected = catalog.find(e => e.id === catalogId);
+      if (selected && selected.weight === value) {
+        return; // Keep current selection
+      }
+    }
+
+    // If exactly one match, auto-select it
+    if (matches.length === 1) {
+      onCatalogIdChange(matches[0].id);
+    } else if (matches.length === 0) {
+      // No matches, clear selection to prevent stale catalog ID
+      if (catalogId !== null) {
+        onCatalogIdChange(null);
+      }
+    }
+    // If multiple matches, don't auto-select - let user choose
+  }, [value, catalog, catalogId, onCatalogIdChange]);
+
   const filtered = useMemo(() => {
     if (!search) return catalog;
     const s = search.toLowerCase();
@@ -39,17 +71,29 @@ function SpoolWeightPicker({
     );
   }, [catalog, search]);
 
-  // Display value: show catalog name if selected, or the weight
+  // Find all entries matching the current weight
+  const matchingEntries = useMemo(() => {
+    return catalog.filter(e => e.weight === value);
+  }, [catalog, value]);
+
+  // Display value: show catalog name if selected by ID, otherwise show first match
   const displayValue = useMemo(() => {
     if (isOpen) return search;
-    if (selectedId) {
-      const entry = catalog.find(e => e.id === selectedId);
+
+    // If a catalog ID is explicitly selected, use that
+    if (catalogId) {
+      const entry = catalog.find(e => e.id === catalogId);
       if (entry) return entry.name;
     }
-    const match = catalog.find(e => e.weight === value);
-    if (match) return match.name;
+
+    // Otherwise, show the first matching entry as a suggestion
+    if (matchingEntries.length > 0) {
+      return matchingEntries[0].name;
+    }
+
+    // Leave empty if there are no matches
     return '';
-  }, [isOpen, search, selectedId, catalog, value]);
+  }, [isOpen, search, catalogId, catalog, matchingEntries]);
 
   return (
     <div>
@@ -86,12 +130,12 @@ function SpoolWeightPicker({
                     key={entry.id}
                     type="button"
                     className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
-                      (selectedId ? entry.id === selectedId : entry.weight === value)
+                      (catalogId ? entry.id === catalogId : entry.weight === value)
                         ? 'bg-bambu-green/10 text-bambu-green'
                         : 'text-white'
                     }`}
                     onClick={() => {
-                      setSelectedId(entry.id);
+                      onCatalogIdChange(entry.id);
                       onChange(entry.weight);
                       setIsOpen(false);
                       setSearch('');
@@ -150,6 +194,8 @@ export function AdditionalSection({
         catalog={spoolCatalog}
         value={formData.core_weight}
         onChange={(weight) => updateField('core_weight', weight)}
+        catalogId={formData.core_weight_catalog_id}
+        onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
       />
 
       {/* Current Weight (remaining filament) */}

+ 4 - 4
frontend/src/components/spool-form/ColorSection.tsx

@@ -177,7 +177,7 @@ export function ColorSection({
                 key={`${color.hex}-${color.name}`}
                 type="button"
                 onClick={() => selectColor(color.hex, color.name)}
-                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
                   isSelected(color.hex)
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
@@ -185,7 +185,7 @@ export function ColorSection({
                 style={{ backgroundColor: `#${color.hex}` }}
                 title={color.name}
               >
-                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
                   {color.name}
                 </span>
               </button>
@@ -220,7 +220,7 @@ export function ColorSection({
                 key={color.hex}
                 type="button"
                 onClick={() => selectColor(color.hex, color.name)}
-                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
                   isSelected(color.hex)
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
@@ -228,7 +228,7 @@ export function ColorSection({
                 style={{ backgroundColor: `#${color.hex}` }}
                 title={color.name}
               >
-                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
                   {color.name}
                 </span>
               </button>

+ 2 - 2
frontend/src/components/spool-form/FilamentSection.tsx

@@ -161,7 +161,7 @@ export function FilamentSection({
 
       {/* Brand (dropdown with search) */}
       <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')}</label>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
         <div className="relative" ref={brandRef}>
           <input
             type="text"
@@ -221,7 +221,7 @@ export function FilamentSection({
 
       {/* Variant / Subtype */}
       <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
         <div className="relative" ref={subtypeRef}>
           <input
             type="text"

+ 10 - 0
frontend/src/components/spool-form/types.ts

@@ -9,6 +9,7 @@ export interface SpoolFormData {
   rgba: string;
   label_weight: number;
   core_weight: number;
+  core_weight_catalog_id: number | null;
   weight_used: number;
   slicer_filament: string;
   note: string;
@@ -22,6 +23,7 @@ export const defaultFormData: SpoolFormData = {
   rgba: '808080FF',
   label_weight: 1000,
   core_weight: 250,
+  core_weight_catalog_id: null,
   weight_used: 0,
   slicer_filament: '',
   note: '',
@@ -115,6 +117,14 @@ export function validateForm(formData: SpoolFormData): ValidationResult {
     errors.material = 'Material is required';
   }
 
+  if (!formData.brand) {
+    errors.brand = 'Brand is required';
+  }
+
+  if (!formData.subtype) {
+    errors.subtype = 'Subtype is required';
+  }
+
   return {
     isValid: Object.keys(errors).length === 0,
     errors,

+ 8 - 11
frontend/src/hooks/useFilamentMapping.ts

@@ -58,7 +58,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
         label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
         globalTrayId: trayId,
         trayInfoIdx: extTray.tray_info_idx || '',
-        extruderId: hasDualNozzle ? (trayId - 254) : undefined,
+        extruderId: hasDualNozzle ? (255 - trayId) : undefined,
       });
     }
   }
@@ -100,12 +100,11 @@ export function computeAmsMapping(
     // Get available trays (not already used)
     let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
 
-    // Nozzle-aware filtering: restrict to trays on the correct nozzle
+    // Nozzle-aware filtering: restrict to trays on the correct nozzle.
+    // This is a hard filter — cross-nozzle assignment causes print failures
+    // ("position of left hotend is abnormal"), so we never fall back to wrong-nozzle trays.
     if (req.nozzle_id != null) {
-      const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
-      if (nozzleFiltered.length > 0) {
-        available = nozzleFiltered;
-      }
+      available = available.filter((f) => f.extruderId === req.nozzle_id);
     }
 
     let idxMatch: LoadedFilament | undefined;
@@ -336,12 +335,10 @@ export function useFilamentMapping(
       // Get available trays (not already used)
       let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
 
-      // Nozzle-aware filtering: restrict to trays on the correct nozzle
+      // Nozzle-aware filtering: restrict to trays on the correct nozzle.
+      // This is a hard filter — cross-nozzle assignment causes print failures.
       if (req.nozzle_id != null) {
-        const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
-        if (nozzleFiltered.length > 0) {
-          available = nozzleFiltered;
-        }
+        available = available.filter((f) => f.extruderId === req.nozzle_id);
       }
 
       let idxMatch: LoadedFilament | undefined;

+ 4 - 1
frontend/src/i18n/index.ts

@@ -8,6 +8,7 @@ import de from './locales/de';
 import fr from './locales/fr';
 import ja from './locales/ja';
 import it from './locales/it';
+import ptBR from './locales/pt-BR';
 
 const resources = {
   en: { translation: en },
@@ -15,6 +16,7 @@ const resources = {
   fr: { translation: fr },
   ja: { translation: ja },
   it: { translation: it },
+  'pt-BR': { translation: ptBR },
 };
 
 i18n
@@ -23,7 +25,7 @@ i18n
   .init({
     resources,
     fallbackLng: 'en',
-    supportedLngs: ['en', 'de', 'fr', 'ja', 'it'],
+    supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR'],
 
     detection: {
       // Order of detection methods
@@ -52,4 +54,5 @@ export const availableLanguages = [
   { code: 'fr', name: 'French', nativeName: 'Français' },
   { code: 'ja', name: 'Japanese', nativeName: '日本語' },
   { code: 'it', name: 'Italian', nativeName: 'Italiano' },
+  { code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
 ];

+ 16 - 0
frontend/src/i18n/locales/de.ts

@@ -2682,6 +2682,8 @@ export default {
     kFactor: 'K-Faktor',
     fill: 'Füllstand',
     configure: 'Konfigurieren',
+    used: 'verwendet',
+    remainingUnit: 'verbleibend',
   },
 
   // Print modal
@@ -3433,4 +3435,18 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: 'Wartend',
+    justNow: 'Gerade eben',
+    now: 'Jetzt',
+    minsAgo: 'vor {{count}}m',
+    inMins: 'in {{count}}m',
+    hoursAgo: 'vor {{count}}h',
+    inHours: 'in {{count}}h',
+    daysAgo: 'vor {{count}}d',
+    inDays: 'in {{count}}d',
+  },
 };

+ 16 - 0
frontend/src/i18n/locales/en.ts

@@ -2686,6 +2686,8 @@ export default {
     kFactor: 'K Factor',
     fill: 'Fill',
     configure: 'Configure',
+    used: 'used',
+    remainingUnit: 'remaining',
   },
 
   // Print modal
@@ -3438,4 +3440,18 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: 'Waiting',
+    justNow: 'Just now',
+    now: 'Now',
+    minsAgo: '{{count}}m ago',
+    inMins: 'in {{count}}m',
+    hoursAgo: '{{count}}h ago',
+    inHours: 'in {{count}}h',
+    daysAgo: '{{count}}d ago',
+    inDays: 'in {{count}}d',
+  },
 };

+ 16 - 0
frontend/src/i18n/locales/fr.ts

@@ -2674,6 +2674,8 @@ export default {
     kFactor: 'Facteur K',
     fill: 'Remplir',
     configure: 'Configurer',
+    used: 'utilisé',
+    remainingUnit: 'restant',
   },
 
   // Print modal
@@ -3426,4 +3428,18 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: 'En attente',
+    justNow: 'À l\'instant',
+    now: 'Maintenant',
+    minsAgo: 'il y a {{count}}m',
+    inMins: 'dans {{count}}m',
+    hoursAgo: 'il y a {{count}}h',
+    inHours: 'dans {{count}}h',
+    daysAgo: 'il y a {{count}}j',
+    inDays: 'dans {{count}}j',
+  },
 };

+ 18 - 2
frontend/src/i18n/locales/it.ts

@@ -2347,7 +2347,7 @@ export default {
     kProfiles: 'K-Profiles',
     addKProfile: 'Aggiungi K-Profile',
     assignSpool: 'Assegna Bobina',
-    unassignSpool: 'Deassegna',
+    unassignSpool: 'Scollega',
     assignSuccess: 'Bobina assegnata e slot AMS configurato',
     assignFailed: 'Assegnazione bobina fallita',
     selectSpool: 'Seleziona una bobina da assegnare a questo slot',
@@ -2397,8 +2397,10 @@ export default {
     externalSpool: 'Bobina esterna',
     profile: 'Profilo',
     kFactor: 'K Factor',
-    fill: 'Riempi',
+    fill: 'Livello',
     configure: 'Configura',
+    used: 'utilizzato',
+    remainingUnit: 'rimanente',
   },
 
   // Print modal
@@ -2814,4 +2816,18 @@ export default {
     configuring: 'Configurazione...',
     configureSlot: 'Configura slot',
   },
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: 'In attesa',
+    justNow: 'Proprio ora',
+    now: 'Ora',
+    minsAgo: '{{count}}m fa',
+    inMins: 'tra {{count}}m',
+    hoursAgo: '{{count}}h fa',
+    inHours: 'tra {{count}}h',
+    daysAgo: '{{count}}g fa',
+    inDays: 'tra {{count}}g',
+  },
 };

+ 16 - 0
frontend/src/i18n/locales/ja.ts

@@ -2604,6 +2604,8 @@ export default {
     kFactor: 'K値',
     fill: '充填率',
     configure: '設定',
+    used: '使用済み',
+    remainingUnit: '残り',
   },
   printModal: {
     flowCalibration: 'フローキャリブレーション',
@@ -3269,4 +3271,18 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: '待機中',
+    justNow: 'たった今',
+    now: '今すぐ',
+    minsAgo: '{{count}}分前',
+    inMins: 'あと{{count}}分',
+    hoursAgo: '{{count}}時間前',
+    inHours: 'あと{{count}}時間',
+    daysAgo: '{{count}}日前',
+    inDays: 'あと{{count}}日',
+  },
 };

+ 3441 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -0,0 +1,3441 @@
+export default {
+  // Navigation
+  nav: {
+    printers: 'Impressoras',
+    archives: 'Arquivos',
+    queue: 'Fila',
+    stats: 'Estatísticas',
+    profiles: 'Perfis',
+    maintenance: 'Manutenção',
+    projects: 'Projetos',
+    inventory: 'Inventário',
+    files: 'Gerenciador de Arquivos',
+    settings: 'Configurações',
+    system: 'Sistema',
+    collapseSidebar: 'Recolher barra lateral',
+    expandSidebar: 'Expandir barra lateral',
+    update: 'Atualizar',
+    updateAvailable: 'Atualização disponível: v{{version}}',
+    updateAvailableBanner: 'Versão {{version}} está disponível!',
+    viewUpdate: 'Ver atualização',
+    viewOnGithub: 'Ver no GitHub',
+    keyboardShortcuts: 'Atalhos de teclado (?)',
+    switchToLight: 'Mudar para modo claro',
+    switchToDark: 'Mudar para modo escuro',
+    smartSwitches: 'Interruptores inteligentes',
+    logout: 'Sair',
+  },
+
+  // Common
+  common: {
+    save: 'Salvar',
+    saving: 'Salvando...',
+    cancel: 'Cancelar',
+    delete: 'Excluir',
+    edit: 'Editar',
+    add: 'Adicionar',
+    close: 'Fechar',
+    confirm: 'Confirmar',
+    loading: 'Carregando...',
+    error: 'Erro',
+    success: 'Sucesso',
+    warning: 'Aviso',
+    enabled: 'Ativado',
+    disabled: 'Desativado',
+    yes: 'Sim',
+    no: 'Não',
+    on: 'Ligado',
+    off: 'Desligado',
+    all: 'Todos',
+    none: 'Nenhum',
+    search: 'Pesquisar',
+    filter: 'Filtrar',
+    sort: 'Ordenar',
+    refresh: 'Atualizar',
+    download: 'Baixar',
+    upload: 'Enviar',
+    actions: 'Ações',
+    status: 'Status',
+    name: 'Nome',
+    description: 'Descrição',
+    date: 'Data',
+    time: 'Hora',
+    hours: 'Horas',
+    minutes: 'Minutos',
+    seconds: 'Segundos',
+    days: 'Dias',
+    enable: 'Ativar',
+    disable: 'Desativar',
+    permissions: 'Permissões',
+    noPrinters: 'Nenhuma impressora configurada',
+    noData: 'Nenhum dado disponível',
+    linkNotFound: 'Link não encontrado',
+    required: 'Obrigatório',
+    optional: 'Opcional',
+    dismiss: 'Dispensar',
+    apply: 'Aplicar',
+    reset: 'Redefinir',
+    export: 'Exportar',
+    import: 'Importar',
+    clear: 'Limpar',
+    selectAll: 'Selecionar tudo',
+    deselectAll: 'Desmarcar tudo',
+    noChange: '— Sem alterações —',
+    unchanged: 'Inalterado',
+    unassigned: 'Não atribuído',
+    unknown: 'Desconhecido',
+    unknownError: 'Erro desconhecido',
+    today: 'Hoje',
+    tomorrow: 'Amanhã',
+    asap: 'O mais rápido possível',
+    overdue: 'Atrasado',
+    now: 'Agora',
+    collapse: 'Recolher',
+    expand: 'Expandir',
+    viewArchive: 'Ver arquivo',
+    viewInFileManager: 'Ver no Gerenciador de Arquivos',
+    addedBy: 'Adicionado por {{username}}',
+    prints: 'impressões',
+    more: '+{{count}} mais',
+    ascending: 'Crescente',
+    descending: 'Decrescente',
+    printer: 'Impressora',
+    remove: 'Remover',
+    type: 'Tipo',
+    print: 'Imprimir',
+    rename: 'Renomear',
+    move: 'Mover',
+    create: 'Criar',
+    duplicate: 'Duplicar',
+    left: 'Esquerda',
+    right: 'Direita',
+  },
+
+  // Printers page
+  printers: {
+    title: 'Impressoras',
+    addPrinter: 'Adicionar Impressora',
+    editPrinter: 'Editar Impressora',
+    deletePrinter: 'Excluir Impressora',
+    printerName: 'Nome da Impressora',
+    serialNumber: 'Número de Série',
+    ipAddress: 'Endereço IP / Nome do Host',
+    accessCode: 'Código de Acesso',
+    model: 'Modelo',
+    nozzleCount: 'Número de Bicos',
+    autoArchive: 'Arquivamento Automático',
+    status: {
+      available: 'Disponível',
+      idle: 'Ocioso',
+      printing: 'Imprimindo',
+      paused: 'Pausado',
+      offline: 'Offline',
+      error: 'Erro',
+      finished: 'Concluído',
+      unknown: 'Desconhecido',
+    },
+    temperatures: {
+      nozzle: 'Bico',
+      bed: 'Cama',
+      chamber: 'Câmara',
+    },
+    progress: '{{percent}}% concluído',
+    timeRemaining: '{{time}} restante',
+    deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+    maintenanceOk: 'Manutenção OK',
+    maintenanceWarning: '{{count}} aviso',
+    maintenanceWarning_plural: '{{count}} avisos',
+    maintenanceDue: '{{count}} devido',
+    maintenanceDue_plural: '{{count}} devido',
+    // Sort options
+    sort: {
+      name: 'Nome',
+      status: 'Status',
+      model: 'Modelo',
+      location: 'Localização',
+      ascending: 'Ordem crescente',
+      descending: 'Ordem decrescente',
+    },
+    // Card size
+    cardSize: {
+      small: 'Cartões pequenos',
+      medium: 'Cartões médios',
+      large: 'Cartões grandes',
+      extraLarge: 'Cartões extra grandes',
+    },
+    // Controls
+    hideOffline: 'Ocultar offline',
+    nextAvailable: 'Próximo disponível',
+    powerOn: 'Ligar',
+    offlinePrintersWithPlugs: 'Impressoras offline com tomadas inteligentes',
+    noPrintersConfigured: 'Nenhuma impressora configurada ainda',
+    // Printer card
+    readyToPrint: 'Pronto para imprimir',
+    external: 'Externo',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
+    deleteArchives: 'Excluir arquivos de impressão',
+    noLabel: 'Sem etiqueta',
+    printPreview: 'Pré-visualização de impressão',
+    width: 'Largura',
+    height: 'Altura',
+    noObjectsFound: 'Nenhum objeto encontrado',
+    objectsLoadedOnPrintStart: 'Objetos são carregados quando uma impressão começa',
+    willBeSkipped: 'Será ignorado',
+    name: 'Nome',
+    serialCannotBeChanged: 'Número de série não pode ser alterado',
+    locationHelp: 'Usado para agrupar impressoras e filtrar trabalhos na fila',
+    // WiFi signal strength
+    wifiSignal: {
+      veryWeak: 'Muito fraco',
+      weak: 'Fraco',
+      fair: 'Regular',
+      good: 'Bom',
+      excellent: 'Excelente',
+    },
+    // Maintenance
+    maintenanceUpToDate: 'Toda a manutenção está em dia - Clique para ver',
+    // Chamber light
+    chamberLightOn: 'Ligar luz da câmara',
+    chamberLightOff: 'Desligar luz da câmara',
+    // Files
+    browseFiles: 'Procurar arquivos da impressora',
+    // Smart plug
+    autoOffAfterPrint: 'Desligamento automático após impressão',
+    autoOffExecuted: 'Desligamento automático executado - ligue a impressora para reiniciar',
+    // HMS errors
+    hmsErrors: 'Erros HMS',
+    viewHmsErrors: 'Ver {{count}} erro(s) HMS',
+    // Actions
+    resume: 'Retomar',
+    pause: 'Pausar',
+    stop: 'Parar',
+    camera: 'âmera',
+    skipObject: 'Ignorar objeto',
+    reconnect: 'Reconectar',
+    mqttDebug: 'Depuração MQTT',
+    activeNozzle: 'Ativo: {{nozzle}} bico',
+    nozzleRack: 'Suporte de bicos',
+    nozzleDocked: 'Acoplado',
+    nozzleMounted: 'Montado',
+    nozzleActive: 'Ativo',
+    nozzleIdle: 'Ocioso',
+    nozzleDiameter: 'Diâmetro',
+    nozzleType: 'Tipo',
+    nozzleStatus: 'Status',
+    nozzleFilament: 'Filamento',
+    nozzleWear: 'Desgaste',
+    nozzleMaxTemp: 'Temp Máx',
+    nozzleSerial: 'Serial',
+    nozzleHardenedSteel: 'Aço Endurecido',
+    nozzleStainlessSteel: 'Aço Inoxidável',
+    nozzleTungstenCarbide: 'Carboneto de Tungstênio',
+    nozzleFlow: 'Fluxo',
+    nozzleHighFlow: 'Alto Fluxo',
+    nozzleStandardFlow: 'Fluxo Padrão',
+    // Firmware
+    firmwareUpdate: 'Atualização de Firmware',
+    firmwareInstructions: 'No visor da impressora, vá para',
+    firmwareNav: 'Navegar para',
+    settings: 'Configurações',
+    firmware: 'Firmware',
+    // Discovery
+    discoverPrinters: 'Descobrir Impressoras',
+    searching: 'Procurando...',
+    manualEntry: 'Entrada Manual',
+    addFromCloud: 'Adicionar da Nuvem',
+    // Toast messages
+    toast: {
+      printerDeleted: 'Impressora excluída',
+      printerAdded: 'Impressora adicionada',
+      printerUpdated: 'Impressora atualizada',
+      failedToDelete: 'Falha ao excluir impressora',
+      failedToAdd: 'Falha ao adicionar impressora',
+      failedToUpdate: 'Falha ao atualizar impressora',
+      commandSent: 'Comando enviado',
+      failedToSendCommand: 'Falha ao enviar comando',
+      turnedOn: '{{name}} ligado',
+      failedToPowerOn: 'Falha ao ligar {{name}}',
+      scriptTriggered: 'Script acionado',
+      printStopped: 'Impressão parada',
+      printPaused: 'Impressão pausada',
+      printResumed: 'Impressão retomada',
+      referenceDeleted: 'Referência excluída',
+      detectionAreaSaved: 'Área de detecção salva',
+      failedToRunScript: 'Falha ao executar script',
+      failedToStopPrint: 'Falha ao parar impressão',
+      failedToPausePrint: 'Falha ao pausar impressão',
+      failedToResumePrint: 'Falha ao retomar impressão',
+      failedToControlChamberLight: 'Falha ao controlar a luz da câmara',
+      failedToUpdateSetting: 'Falha ao atualizar configuração',
+      failedToSkipObjects: 'Falha ao ignorar objetos',
+      failedToRereadRfid: 'Falha ao reler RFID',
+      failedToCheckPlate: 'Falha ao verificar a placa',
+      failedToUpdateLabel: 'Falha ao atualizar etiqueta',
+      failedToDeleteReference: 'Falha ao excluir referência',
+      failedToSaveDetectionArea: 'Falha ao salvar área de detecção',
+      plateCheckEnabled: 'Verificação da placa ativada',
+      plateCheckDisabled: 'Verificação da placa desativada',
+      calibrationSaved: 'Calibração salva!',
+      calibrationFailed: 'Falha na calibração',
+      rfidRereadInitiated: 'Releitura de RFID iniciada',
+    },
+    // Connection status
+    connection: {
+      connected: 'Conectado',
+      offline: 'Offline',
+    },
+    // Queue info
+    queue: {
+      inQueue: '{{count}} impressão na fila',
+      inQueue_plural: '{{count}} impressões na fila',
+    },
+    // Controls section
+    controls: 'Controles',
+    // RFID
+    rfid: {
+      reread: 'Releitura de RFID',
+    },
+    // Permissions
+    permission: {
+      noAdd: 'Você não tem permissão para adicionar impressoras',
+      noEdit: 'Você não tem permissão para editar impressoras',
+      noDelete: 'Você não tem permissão para excluir impressoras',
+      noControl: 'Você não tem permissão para controlar impressoras',
+      noFiles: 'Você não tem permissão para acessar arquivos de impressora',
+      noAmsRfid: 'Você não tem permissão para reler RFID AMS',
+      noSmartPlugControl: 'Você não tem permissão para controlar tomadas inteligentes',
+    },
+    // Add/Edit modal
+    modal: {
+      addTitle: 'Adicionar Impressora',
+      editTitle: 'Editar Impressora',
+      myPrinter: 'Minha Impressora',
+      selectModel: 'Selecionar modelo...',
+      locationGroup: 'Localização / Grupo (opcional)',
+      locationPlaceholder: 'ex.: Oficina, Escritório, Porão',
+      autoArchiveLabel: 'Arquivar automaticamente impressões concluídas',
+      fromPrinterSettings: 'A partir das configurações da impressora',
+      modelOptional: 'Modelo (opcional)',
+      saveChanges: 'Salvar alterações',
+    },
+    // Skip objects
+    skipObjects: {
+      tooltip: 'Ignorar objetos',
+      onlyWhilePrinting: 'Ignorar objetos (apenas durante a impressão)',
+      requiresMultiple: 'Ignorar objetos (requer 2+ objetos)',
+      title: 'Ignorar Objetos',
+      matchIdsInfo: 'Correspondência de IDs com o display da sua impressora',
+      printerShowsIds: 'A tela da impressora mostra os IDs dos objetos na placa de construção',
+      skipSelected: 'Ignorar Selecionados',
+      skipping: 'Ignorando...',
+      noObjectsSelected: 'Nenhum objeto selecionado',
+      selectObjectsToSkip: 'Selecione os objetos que deseja ignorar na impressão atual',
+      skipped: 'Ignorado',
+      objectsSkipped: 'Objetos ignorados',
+      activeCount: '{{count}} ativo',
+      waitForLayer: 'Aguarde a camada 2+ para ignorar objetos (atualmente na camada {{layer}})',
+      skip: 'Ignorar',
+      confirmTitle: 'Ignorar Objeto?',
+      confirmMessage: 'Tem certeza de que deseja ignorar "{{name}}"? Isso não pode ser desfeito.',
+    },
+    // Confirm modals
+    confirm: {
+      deleteTitle: 'Excluir Impressora',
+      deleteMessage: 'Tem certeza de que deseja excluir "{{name}}"? Isso removerá todas as configurações de conexão.',
+      deleteArchivesNote: 'Todo o histórico de impressão desta impressora será permanentemente excluído.',
+      keepArchivesNote: 'O histórico de impressão será mantido, mas não estará mais associado a esta impressora.',
+      stopTitle: 'Parar Impressão',
+      stopMessage: 'Tem certeza de que deseja parar a impressão atual em "{{name}}"? Isso cancelará o trabalho de impressão.',
+      stopButton: 'Parar Impressão',
+      pauseTitle: 'Pausar Impressão',
+      pauseMessage: 'Tem certeza de que deseja pausar a impressão atual em "{{name}}"?',
+      pauseButton: 'Pausar Impressão',
+      resumeTitle: 'Retomar Impressão',
+      resumeMessage: 'Tem certeza de que deseja retomar a impressão em "{{name}}"?',
+      resumeButton: 'Retomar Impressão',
+      powerOnTitle: 'Ligar Impressora',
+      powerOnMessage: 'Tem certeza de que deseja ligar a impressora "{{name}}"?',
+      powerOnButton: 'Ligar',
+      powerOffTitle: 'Desligar Impressora',
+      powerOffMessage: 'Tem certeza de que deseja desligar a impressora "{{name}}"?',
+      powerOffWarning: 'AVISO: "{{name}}" está imprimindo no momento! Tem certeza de que deseja desligar a impressora? Isso interromperá a impressão e pode danificar a impressora.',
+      powerOffButton: 'Desligar',
+    },
+    // Discovery
+    discovery: {
+      title: 'Descobrir Impressoras',
+      searching: 'Procurando...',
+      scanning: 'Escaneando...',
+      scanProgress: 'Escaneando... {{scanned}}/{{total}}',
+      foundPrinters: '{{count}} impressora(s) encontrada(s)',
+      noPrintersFound: 'Nenhuma impressora encontrada',
+      noPrintersFoundSubnet: 'Nenhuma impressora encontrada na sub-rede especificada.',
+      noPrintersFoundNetwork: 'Nenhuma impressora encontrada na rede.',
+      allConfigured: 'Todas as impressoras descobertas já estão configuradas.',
+      alreadyAdded: 'Já adicionada',
+      select: 'Selecionar',
+      manualEntry: 'Entrada Manual',
+      addFromCloud: 'Adicionar da Nuvem',
+      subnetToScan: 'Sub-rede para escanear',
+      dockerNote: 'Docker detectado. Insira a sub-rede da sua impressora em notação CIDR. Requer network_mode: host no docker-compose.yml.',
+      scanSubnet: 'Escanear Sub-rede para Impressoras',
+      discoverNetwork: 'Descobrir Impressoras na Rede',
+      scanningSubnet: 'Escaneando sub-rede para impressoras Bambu...',
+      scanningNetwork: 'Escaneando rede...',
+      serialRequired: 'Serial necessário',
+      unknown: 'Desconhecido',
+      failedToStart: 'Falha ao iniciar a descoberta',
+    },
+    // Filaments section
+    filaments: 'Filamentos',
+    // Camera
+    openCameraOverlay: 'Abrir sobreposição da câmera',
+    openCameraWindow: 'Abrir câmera em nova janela',
+    // Firmware
+    firmwareUpdateAvailable: 'Atualização de firmware disponível: {{current}} → {{latest}}',
+    firmwareUpToDate: 'Firmware {{version}} — Atualizado',
+    firmwareUpdateButton: 'Atualizar',
+    // Plate detection
+    plateDetection: {
+      noPermission: 'Você não tem permissão para atualizar impressoras',
+      enabledClick: 'Verificação da placa ativada - Clique para desativar',
+      disabledClick: 'Verificação da placa desativada - Clique para ativar',
+      manageCalibration: 'Gerenciar calibração da detecção da placa',
+      calibrationRequired: 'Calibração necessária',
+      calibrationInstructions: 'Certifique-se de que a placa de construção esteja <strong>completamente vazia</strong>, em seguida clique em Calibrar.',
+      calibrationDescription: 'A calibração captura uma imagem de referência da placa vazia. Verificações futuras compararão com esta referência para detectar objetos.',
+      calibrationTip: '<strong>Dica:</strong> Você pode armazenar até 5 calibrações para diferentes placas. O sistema usa automaticamente a melhor correspondência ao verificar.',
+      plateEmpty: 'A placa parece vazia',
+      objectsDetected: 'Objetos detectados na placa',
+      confidence: 'Confiança',
+      difference: 'Diferença',
+      analysisPreview: 'Pré-visualização da análise:',
+      analysisLegend: 'Caixa verde = área de detecção, Sobreposição vermelha = diferenças em relação à calibração',
+      savedReferences: 'Referências salvas ({{count}}/{{max}})',
+      deleteReference: 'Excluir referência',
+      labelPlaceholder: 'Etiqueta...',
+      clickToEdit: '{{label}} - Clique para editar',
+      clickToAddLabel: 'Clique para adicionar etiqueta',
+    },
+    // Fans
+    fans: {
+      partCooling: 'Ventilador de resfriamento da peça',
+      auxiliary: 'Ventilador auxiliar',
+      chamber: 'Ventilador da câmara',
+    },
+    // HMS errors
+    clickToViewHmsErrors: 'Clique para ver erros do HMS',
+    estimatedCompletion: 'Tempo estimado de conclusão',
+    slotOptions: 'Opções de slot',
+    // Firmware modal
+    firmwareModal: {
+      title: 'Atualização de Firmware',
+      titleUpToDate: 'Informações do Firmware',
+      currentVersion: 'Atual:',
+      latestVersion: 'Última:',
+      releaseNotes: 'Notas de Lançamento',
+      checkingPrereqs: 'Verificando pré-requisitos...',
+      sdCardReady: 'Cartão SD pronto. Clique abaixo para enviar o firmware.',
+      uploadedSuccess: 'Firmware enviado para o cartão SD!',
+      applyInstructions: 'Para aplicar a atualização na sua impressora:',
+      step1: 'Na tela sensível ao toque da impressora, vá para <strong>Configurações</strong>',
+      step2: 'Navegue até <strong>Firmware</strong>',
+      step3: 'Selecione <strong>Atualizar a partir do cartão SD</strong>',
+      step4: 'A atualização levará de 10 a 20 minutos',
+      done: 'Concluído',
+      starting: 'Iniciando...',
+      uploadFirmware: 'Enviar Firmware',
+      uploadFailed: 'Falha ao iniciar o envio: {{error}}',
+      uploadedToast: 'Firmware enviado! Inicie a atualização na tela da impressora.',
+    },
+    accessCodePlaceholder: 'Deixe vazio para manter o atual',
+    // ROI editor
+    roi: {
+      title: 'Área de Detecção (ROI)',
+      xStart: 'Início X',
+      yStart: 'Início Y',
+      width: 'Largura',
+      height: 'Altura',
+      instruction: 'Ajuste a área de detecção para focar na placa de construção. A caixa verde na pré-visualização mostra a área atual.',
+    },
+  },
+
+  // Archives page
+  archives: {
+    title: 'Arquivos de Impressão',
+    searchPlaceholder: 'Pesquisar arquivos...',
+    filterByPrinter: 'Filtrar por impressora',
+    filterByStatus: 'Filtrar por status',
+    sortBy: 'Ordenar por',
+    sortNewest: 'Mais recentes primeiro',
+    sortOldest: 'Mais antigos primeiro',
+    sortName: 'Nome',
+    sortDuration: 'Duração',
+    sortLargest: 'Maiores primeiro',
+    sortSmallest: 'Menores primeiro',
+    sortSize: 'Tamanho',
+    noArchives: 'Nenhum arquivo encontrado',
+    noArchivesSearch: 'Nenhum arquivo corresponde à sua pesquisa',
+    noArchivesYet: 'Ainda não há arquivos',
+    loadingArchives: 'Carregando arquivos...',
+    releaseToUpload: 'Solte para enviar',
+    showAll: 'Mostrar todos',
+    showFavoritesOnly: 'Mostrar apenas favoritos',
+    gridView: 'Visualização em grade',
+    listView: 'Visualização em lista',
+    calendarView: 'Visualização em calendário',
+    logView: 'Registro de impressão',
+    manageTags: 'Gerenciar etiquetas',
+    showFailedPrints: 'Mostrar impressões falhas',
+    hideFailedPrints: 'Ocultar impressões falhas',
+    printTime: 'Tempo de impressão',
+    filamentUsed: 'Filamento usado',
+    cost: 'Custo',
+    reprint: 'Reimprimir',
+    preview: 'Pré-visualizar',
+    deleteArchive: 'Excluir arquivo',
+    deleteConfirm: 'Tem certeza de que deseja excluir este arquivo?',
+    favorite: 'Favorito',
+    unfavorite: 'Remover dos favoritos',
+    viewDetails: 'Ver detalhes',
+    status: {
+      completed: 'Concluído',
+      failed: 'Falhou',
+      stopped: 'Parado',
+    },
+    toast: {
+      source3mfAttached: 'Arquivo de origem 3MF anexado: {{filename}}',
+      failedUploadSource3mf: 'Falha ao enviar arquivo de origem 3MF',
+      source3mfRemoved: 'Arquivo de origem 3MF removido',
+      failedRemoveSource3mf: 'Falha ao remover arquivo de origem 3MF',
+      f3dAttached: 'F3D anexado: {{filename}}',
+      failedUploadF3d: 'Falha ao enviar F3D',
+      f3dRemoved: 'F3D removido',
+      failedRemoveF3d: 'Falha ao remover F3D',
+      timelapseAttached: 'Timelapse anexado: {{filename}}',
+      timelapseAlreadyAttached: 'Timelapse já anexado',
+      noMatchingTimelapse: 'Nenhum timelapse correspondente encontrado',
+      failedScanTimelapse: 'Falha ao escanear timelapse',
+      failedAttachTimelapse: 'Falha ao anexar timelapse',
+      timelapseRemoved: 'Timelapse removido',
+      failedRemoveTimelapse: 'Falha ao remover timelapse',
+      timelapseUploaded: 'Timelapse enviado: {{filename}}',
+      failedUploadTimelapse: 'Falha ao enviar timelapse',
+      archiveDeleted: 'Arquivo excluído',
+      failedDeleteArchive: 'Falha ao excluir arquivo',
+      addedToFavorites: 'Adicionado aos favoritos',
+      removedFromFavorites: 'Removido dos favoritos',
+      projectUpdated: 'Projeto atualizado',
+      failedUpdateProject: 'Falha ao atualizar projeto',
+      linkCopied: 'Link copiado para a área de transferência',
+      failedCopyLink: 'Falha ao copiar link',
+      photoDeleted: 'Foto excluída',
+      failedDeletePhoto: 'Falha ao excluir foto',
+      failedDeleteArchives: 'Falha ao excluir arquivos',
+      failedUpdateFavorites: 'Falha ao atualizar favoritos',
+      exportDownloaded: 'Exportação baixada',
+      exportFailed: 'Falha na exportação',
+    },
+    menu: {
+      print: 'Imprimir',
+      schedule: 'Agendar',
+      openInBambuStudio: 'Abrir no Slicer',
+      slice: 'Fatiar',
+      externalLink: 'Link externo',
+      viewOnMakerWorld: 'Ver no MakerWorld',
+      preview3d: 'Pré-visualização 3D',
+      viewTimelapse: 'Ver Timelapse',
+      scanForTimelapse: 'Escanear Timelapse',
+      uploadTimelapse: 'Enviar Timelapse',
+      removeTimelapse: 'Remover Timelapse',
+      downloadSource3mf: 'Baixar Source 3MF',
+      uploadSource3mf: 'Enviar Source 3MF',
+      replaceSource3mf: 'Substituir Source 3MF',
+      removeSource3mf: 'Remover Source 3MF',
+      uploadF3d: 'Enviar F3D',
+      replaceF3d: 'Substituir F3D',
+      downloadF3d: 'Baixar F3D',
+      removeF3d: 'Remover F3D',
+      download: 'Baixar',
+      copyDownloadLink: 'Copiar link de download',
+      qrCode: 'Qr Code',
+      viewPhotos: 'Ver fotos',
+      viewPhotosCount: 'Ver fotos ({{count}})',
+      projectPage: 'Página do projeto',
+      addToFavorites: 'Adicionar aos favoritos',
+      removeFromFavorites: 'Remover dos favoritos',
+      edit: 'Editar',
+      goToProject: 'Ir para o projeto: {{name}}',
+      addToProject: 'Adicionar ao projeto',
+      removeFromProject: 'Remover do projeto',
+      loading: 'Carregando...',
+      noProjectsAvailable: 'Nenhum projeto disponível',
+      select: 'Selecionar',
+      deselect: 'Desmarcar',
+      delete: 'Excluir',
+    },
+    permission: {
+      noReprint: 'Você não tem permissão para reimprimir este arquivo',
+      noAddToQueue: 'Você não tem permissão para adicionar à fila',
+      noUpdateArchives: 'Você não tem permissão para atualizar arquivos',
+      noUploadFiles: 'Você não tem permissão para enviar arquivos',
+      noDownload: 'Você não tem permissão para baixar arquivos',
+      noCopyLink: 'Você não tem permissão para copiar links de download',
+      noDelete: 'Você não tem permissão para excluir este arquivo',
+      noCreate: 'Você não tem permissão para criar arquivos',
+    },
+    card: {
+      previousPlate: 'Placa anterior',
+      nextPlate: 'Próxima placa',
+      plateNumber: 'Placa {{index}}',
+      moreOptions: 'Clique com o botão direito para mais opções',
+      addToFavorites: 'Adicionar aos favoritos',
+      removeFromFavorites: 'Remover dos favoritos',
+      cancelled: 'cancelado',
+      failed: 'falha',
+      duplicate: 'duplicado',
+      duplicateTitle: 'Este modelo já foi impresso antes',
+      openSource3mf: 'Abrir source 3MF no Bambu Studio (clique com o botão direito para mais opções)',
+      downloadF3d: 'Baixar arquivo de design do Fusion 360',
+      viewTimelapse: 'Ver timelapse',
+      viewPhoto: 'Ver 1 foto',
+      viewPhotos: 'Ver {{count}} fotos',
+      openFolder: 'Abrir pasta: {{name}}',
+      slicedFile: 'Arquivo fatiado - pronto para imprimir',
+      sourceFile: 'Apenas arquivo fonte - nenhum mapeamento AMS disponível',
+      gcode: 'GCODE',
+      source: 'SOURCE',
+      project: 'Projeto: {{name}}',
+      estimated: 'Estimado: {{time}}',
+      actual: 'Real: {{time}}',
+      accuracy: 'Precisão: {{percent}}%',
+      filament: '{{weight}}g',
+      layer: '{{count}} camada',
+      layers: '{{count}} camadas',
+      object: '{{count}} objeto',
+      objects: '{{count}} objetos',
+      slicedFor: 'Fatiado para {{model}}',
+      uploadedBy: 'Enviado por',
+      noPermissionReprint: 'Você não tem permissão para reimprimir',
+      noFileForReprint: 'Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada',
+      noPermissionEdit: 'Você não tem permissão para editar arquivos',
+      noPermissionDelete: 'Você não tem permissão para excluir arquivos',
+      reprint: 'Reimprimir',
+      schedulePrint: 'Agendar impressão',
+      schedule: 'Agendar',
+      openInBambuStudio: 'Abrir no Bambu Studio',
+      openInBambuStudioToSlice: 'Abrir no Bambu Studio para fatiar',
+      slice: 'Fatiar',
+      externalLink: 'Link externo',
+      makerWorld: 'MakerWorld: {{designer}}',
+      viewProject: 'Ver projeto',
+      noExternalLink: 'Nenhum link externo',
+      preview3d: 'Visualização 3D',
+      download: 'Baixar',
+      edit: 'Editar',
+      delete: 'Excluir',
+    },
+    modal: {
+      deleteArchive: 'Excluir Arquivo',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"? Esta ação não pode ser desfeita.',
+      deleteButton: 'Excluir',
+      removeSource3mf: 'Remover Source 3MF',
+      removeSource3mfConfirm: 'Tem certeza de que deseja remover o arquivo source 3MF de "{{name}}"? Isso excluirá o arquivo original do projeto do fatiador.',
+      removeButton: 'Remover',
+      removeF3d: 'Remover F3D',
+      removeF3dConfirm: 'Tem certeza de que deseja remover o arquivo de design do Fusion 360 de "{{name}}"?',
+      removeTimelapse: 'Remover Timelapse',
+      removeTimelapseConfirm: 'Tem certeza de que deseja remover o vídeo timelapse de "{{name}}"?',
+      timelapse: '{{name}} - Timelapse',
+      selectTimelapse: 'Selecionar Timelapse',
+      selectTimelapseDesc: 'Nenhuma correspondência automática encontrada. Selecione o timelapse para esta impressão:',
+      deleteArchives: 'Excluir Arquivos',
+      deleteArchivesConfirm: 'Tem certeza de que deseja excluir {{count}} arquivo(s)? Esta ação não pode ser desfeita.',
+      deleteCount: 'Excluir {{count}}',
+    },
+    page: {
+      title: 'Arquivos',
+      printsCount: '{{filtered}} de {{total}} impressões',
+      dropFilesHere: 'Solte arquivos .3mf aqui',
+      releaseToUpload: 'Solte para enviar',
+      only3mfSupported: 'Apenas arquivos .3mf são suportados',
+      close: 'Fechar',
+      selected: '{{count}} selecionado(s)',
+      selectAll: 'Selecionar Todos',
+      tags: 'Tags',
+      project: 'Projeto',
+      favorite: 'Favorito',
+      delete: 'Excluir',
+      toggledFavorites: 'Favoritos alternados para {{count}} arquivo(s)',
+      failedUpdateFavorites: 'Falha ao atualizar favoritos',
+      archivesDeleted: '{{count}} arquivo(s) excluído(s)',
+      failedDeleteArchives: 'Falha ao excluir arquivos',
+      photoDeleted: 'Foto excluída',
+      failedDeletePhoto: 'Falha ao excluir foto',
+    },
+    list: {
+      name: 'Nome',
+      printer: 'Impressora',
+      date: 'Data',
+      size: 'Tamanho',
+      actions: 'Ações',
+      hasTimelapse: 'Possui timelapse',
+    },
+    log: {
+      date: 'Data',
+      printName: 'Nome da Impressão',
+      printer: 'Impressora',
+      user: 'Usuário',
+      status: 'Status',
+      duration: 'Duração',
+      filament: 'Filamento',
+      allPrinters: 'Todas as Impressoras',
+      allUsers: 'Todos os Usuários',
+      allStatuses: 'Todos os Status',
+      cancelled: 'Cancelado',
+      skipped: 'Ignorado',
+      dateFrom: 'De',
+      dateTo: 'Até',
+      noEntries: 'Nenhuma entrada de registro de impressão encontrada',
+      showing: 'Mostrando {{count}} de {{total}} entradas',
+      rowsPerPage: 'Linhas',
+      page: 'Página',
+      prev: 'Anterior',
+      next: 'Próxima',
+      clearLog: 'Limpar Registro',
+      clearLogTitle: 'Limpar Registro de Impressão',
+      clearLogConfirm: 'Todas as entradas do registro de impressão serão permanentemente excluídas. Arquivos e itens da fila não serão afetados. Esta ação não pode ser desfeita. Tem certeza?',
+      clearLogButton: 'Limpar Tudo',
+      cleared: '{{count}} entradas do registro de impressão limpas',
+      clearFailed: 'Falha ao limpar o registro de impressão',
+    },
+  },
+
+  // Queue page
+  queue: {
+    title: 'Fila de Impressão',
+    subtitle: 'Agende e gerencie seus trabalhos de impressão',
+    addToQueue: 'Adicionar à Fila',
+    // Print modal
+    print: 'Imprimir',
+    reprint: 'Reimprimir',
+    schedulePrint: 'Agendar Impressão',
+    editQueueItem: 'Editar Item da Fila',
+    printToPrinters: 'Imprimir para {{count}} Impressoras',
+    queueToPrinters: 'Adicionar à Fila para {{count}} Impressoras',
+    sending: 'Enviando...',
+    sendingProgress: 'Enviando {{current}}/{{total}}...',
+    adding: 'Adicionando...',
+    addingProgress: 'Adicionando {{current}}/{{total}}...',
+    savingProgress: 'Salvando {{current}}/{{total}}...',
+    clearQueue: 'Limpar Fila',
+    clearHistory: 'Limpar Histórico',
+    emptyQueue: 'Fila vazia',
+    position: 'Posição',
+    scheduledTime: 'Hora Agendada',
+    moveUp: 'Mover para Cima',
+    moveDown: 'Mover para Baixo',
+    startNow: 'Iniciar Agora',
+    printingInProgress: 'Impressão em andamento...',
+    viewArchive: 'Ver Arquivo',
+    viewInFileManager: 'Ver no Gerenciador de Arquivos',
+    itemCount: '{{count}} item',
+    itemCount_plural: '{{count}} itens',
+    dragToReorder: 'Arraste para reordenar (apenas ASAP)',
+    reorderHint: 'A posição afeta apenas itens ASAP. Itens agendados são executados no horário definido.',
+    addedBy: 'Adicionado por {{name}}',
+    nextInQueue: 'Próximo na fila',
+    clearPlate: 'Limpar Placa e Iniciar Próximo',
+    clearPlateSuccess: 'Placa limpa — pronta para a próxima impressão',
+    plateReady: 'Placa limpa — pronta para a próxima impressão',
+    plateNumber: 'Placa {{index}}',
+    // Sections
+    sections: {
+      currentlyPrinting: 'Imprimindo Atualmente',
+      queued: 'Na Fila',
+      history: 'Histórico',
+    },
+    // Status
+    status: {
+      pending: 'Pendente',
+      waiting: 'Aguardando',
+      printing: 'Imprimindo',
+      paused: 'Pausado',
+      completed: 'Concluído',
+      failed: 'Falhou',
+      skipped: 'Ignorado',
+      cancelled: 'Cancelado',
+    },
+    // Summary cards
+    summary: {
+      printing: 'Imprimindo',
+      queued: 'Na Fila',
+      totalTime: 'Tempo Total da Fila',
+      totalWeight: 'Peso Total da Fila',
+      history: 'Histórico',
+    },
+    // Filters
+    filter: {
+      allPrinters: 'Todas as Impressoras',
+      unassigned: 'Não Atribuído',
+      allStatus: 'Todos os Status',
+      allLocations: 'Todos os Locais',
+      any: 'Qualquer',
+    },
+    // Sort
+    sort: {
+      byPosition: 'Ordenar por Posição',
+      byName: 'Ordenar por Nome',
+      byPrinter: 'Ordenar por Impressora',
+      bySchedule: 'Ordenar por Agendamento',
+      byDate: 'Ordenar por Data',
+      ascendingOldest: 'Crescente (mais antigo primeiro)',
+      descendingNewest: 'Decrescente (mais recente primeiro)',
+    },
+    // Badges
+    badges: {
+      staged: 'Preparado (início manual)',
+      requiresPrevious: 'Requer sucesso anterior',
+      autoPowerOff: 'Desligamento automático',
+    },
+    // Empty state
+    empty: {
+      title: 'Nenhuma impressão agendada',
+      description: 'Agende uma impressão a partir da página de Arquivos usando a opção "Agendar" no menu de contexto, ou arraste e solte arquivos para começar.',
+    },
+    // Time
+    time: {
+      asap: 'ASAP',
+      overdue: 'Atrasado',
+      now: 'Agora',
+      lessThanMinute: 'Em menos de um minuto',
+      inMinutes: 'Em {{count}} min',
+      inHours: 'Em {{count}} horas',
+    },
+    // Actions
+    actions: {
+      stopPrint: 'Parar Impressão',
+      startPrint: 'Iniciar Impressão',
+      requeue: 'Reenfileirar',
+    },
+    // Bulk edit
+    bulkEdit: {
+      title: 'Editar {{count}} Item',
+      title_plural: 'Editar {{count}} Itens',
+      description: 'Apenas as configurações alteradas serão aplicadas aos itens selecionados.',
+      printer: 'Impressora',
+      noChange: '— Sem alterações —',
+      queueOptions: 'Opções de Fila',
+      staged: 'Preparado (início manual)',
+      autoPowerOff: 'Desligamento automático após impressão',
+      requirePrevious: 'Requer sucesso anterior',
+      printOptions: 'Opções de Impressão',
+      bedLevelling: 'Nivelamento da Mesa',
+      flowCalibration: 'Calibração de Fluxo',
+      vibrationCalibration: 'Calibração de Vibração',
+      layerInspection: 'Inspeção da Primeira Camada',
+      timelapse: 'Timelapse',
+      useAms: 'Usar AMS',
+      applyChanges: 'Aplicar Alterações',
+      selectAll: 'Selecionar Todos',
+      deselectAll: 'Desmarcar Todos',
+      selected: '{{count}} selecionado(s)',
+      editSelected: 'Editar Selecionados',
+      cancelSelected: 'Cancelar Selecionados',
+    },
+    // Confirmations
+    confirm: {
+      cancelTitle: 'Cancelar Impressão Agendada',
+      cancelMessage: 'Tem certeza de que deseja cancelar "{{name}}"?',
+      stopTitle: 'Parar Impressão',
+      stopMessage: 'Tem certeza de que deseja parar a impressão atual "{{name}}"? Isso cancelará o trabalho de impressão na impressora.',
+      removeTitle: 'Remover do Histórico',
+      removeMessage: 'Tem certeza de que deseja remover "{{name}}" do histórico da fila?',
+      clearHistoryTitle: 'Limpar Histórico',
+      clearHistoryMessage: 'Tem certeza de que deseja remover todos os {{count}} itens do histórico?',
+      cancelButton: 'Cancelar Impressão',
+      stopButton: 'Parar Impressão',
+      thisPrint: 'esta impressão',
+      thisItem: 'este item',
+    },
+    // Toast messages
+    toast: {
+      cancelled: 'Item da fila cancelado',
+      cancelFailed: 'Falha ao cancelar item',
+      removed: 'Item da fila removido',
+      removeFailed: 'Falha ao remover item',
+      stopped: 'Impressão parada',
+      stopFailed: 'Falha ao parar impressão',
+      released: 'Impressão liberada para a fila',
+      startFailed: 'Falha ao iniciar impressão',
+      reorderFailed: 'Falha ao reordenar fila',
+      historyCleared: 'Limpar {{count}} item(s) do histórico',
+      clearHistoryFailed: 'Falha ao limpar histórico',
+      updateFailed: 'Falha ao atualizar itens',
+      bulkCancelled: 'Cancelado {{count}} item(s)',
+      bulkCancelFailed: 'Falha ao cancelar itens',
+    },
+    // Permissions
+    permissions: {
+      noStopPrint: 'Você não tem permissão para parar impressões',
+      noStartPrint: 'Você não tem permissão para iniciar impressões',
+      noEdit: 'Você não tem permissão para editar este item da fila',
+      noCancel: 'Você não tem permissão para cancelar este item da fila',
+      noRequeue: 'Você não tem permissão para reenfileirar itens',
+      noRemove: 'Você não tem permissão para remover este item da fila',
+      noClearHistory: 'Você não tem permissão para limpar todo o histórico',
+      noEditItems: 'Você não tem permissão para editar itens da fila',
+      noCancelItems: 'Você não tem permissão para cancelar itens da fila',
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: 'Dashboard',
+    subtitle: 'Arraste os widgets para reorganizar. Clique no ícone de olho para ocultar.',
+    overview: 'Visão Geral',
+    totalPrints: 'Total de Impressões',
+    successRate: 'Taxa de Sucesso',
+    totalPrintTime: 'Tempo Total de Impressão',
+    printTime: 'Tempo de Impressão',
+    totalFilament: 'Filamento Total Utilizado',
+    filamentUsed: 'Filamento Utilizado',
+    filamentCost: 'Custo do Filamento',
+    totalCost: 'Custo Total',
+    energyUsed: 'Energia Utilizada',
+    energyCost: 'Custo da Energia',
+    averagePrintTime: 'Tempo Médio de Impressão',
+    printsPerDay: 'Impressões por Dia',
+    byPrinter: 'Por Impressora',
+    printsByPrinter: 'Impressões por Impressora',
+    byMaterial: 'Por Material',
+    byMonth: 'Por Mês',
+    last7Days: 'Últimos 7 Dias',
+    last30Days: 'Últimos 30 Dias',
+    last90Days: 'Últimos 90 Dias',
+    allTime: 'Todo o Tempo',
+    // Widgets
+    quickStats: 'Estatísticas Rápidas',
+    printActivity: 'Atividade de Impressão',
+    filamentTypes: 'Tipos de Filamento',
+    filamentTrends: 'Tendências de Filamento',
+    failureAnalysis: 'Análise de Falhas',
+    timeAccuracy: 'Precisão do Tempo',
+    successful: 'Bem-sucedido:',
+    failed: 'Falhou:',
+    perfectEstimate: '100% = estimativa perfeita',
+    noTimeAccuracyData: 'Nenhum dado de precisão de tempo disponível',
+    noFilamentData: 'Nenhum dado de filamento disponível',
+    noPrinterData: 'Nenhum dado de impressora disponível',
+    noPrintData: 'Nenhum dado de impressão disponível',
+    noPrintDataLast30Days: 'Nenhum dado de impressão nos últimos 30 dias',
+    failureReasons: 'Razões de Falha',
+    topFailureReasons: 'Principais Razões de Falha',
+    failedPrintsCount: '{{failed}} / {{total}} impressões falharam',
+    lastWeekRate: 'Última semana: {{rate}}%',
+    // Actions
+    resetLayout: 'Redefinir Layout',
+    recalculateCosts: 'Recalcular Custos',
+    recalculateCostsHint: 'Recalcular todos os custos do arquivo usando os preços atuais do filamento',
+    exportStats: 'Exportar Estatísticas',
+    exportAsCsv: 'Exportar como CSV',
+    exportAsExcel: 'Exportar como Excel',
+    hiddenCount: '{{count}} Oculto',
+    // Toast
+    exportDownloaded: 'Exportação baixada',
+    exportFailed: 'Falha na exportação',
+    layoutReset: 'Layout redefinido',
+    recalculatedCosts: 'Custos recalculados para {{count}} arquivos',
+    recalculateFailed: 'Falha ao recalcular custos',
+    // Loading
+    loadingStats: 'Carregando estatísticas...',
+    // Permissions
+    noPermissionResetLayout: 'Você não tem permissão para redefinir o layout',
+    noPermissionRecalculate: 'Você não tem permissão para recalcular custos',
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: 'Manutenção',
+    overview: 'Visão Geral',
+    allOk: 'Todas as manutenções estão em dia',
+    dueCount: '{{count}} item pendente',
+    dueCount_plural: '{{count}} itens pendentes',
+    warningCount: '{{count}} aviso',
+    warningCount_plural: '{{count}} avisos',
+    totalPrintTime: 'Tempo Total de Impressão',
+    nextMaintenance: 'Próxima Manutenção',
+    nothingDue: 'Nada pendente',
+    tasks: 'Tarefas',
+    lastPerformed: 'Última execução',
+    interval: 'Intervalo',
+    hoursRemaining: '{{hours}}h restantes',
+    hoursOverdue: '{{hours}}h atrasadas',
+    markDone: 'Marcar como Concluída',
+    performMaintenance: 'Realizar Manutenção',
+    history: 'Histórico',
+    noHistory: 'Nenhum histórico de manutenção',
+    editPrintHours: 'Editar Horas de Impressão',
+    currentHours: 'Horas Atuais',
+    // Tabs
+    statusTab: 'Status',
+    settingsTab: 'Configurações',
+    // Status
+    overdueCount: '{{count}} atrasado',
+    dueSoonCount: '{{count}} prestes a vencer',
+    dueSoon: 'Prestes a vencer',
+    allGood: 'Tudo certo',
+    overdueBy: 'Atrasado por {{duration}}',
+    dueIn: 'Vence em {{duration}}',
+    timeLeft: '{{duration}} restantes',
+    // Duration formats
+    day: '1 dia',
+    days: '{{count}} dias',
+    week: '1 semana',
+    weeks: '{{count}} semanas',
+    month: '1 mês',
+    months: '{{count}} meses',
+    year: '1 ano',
+    // Settings
+    maintenanceTypes: 'Tipos de Manutenção',
+    maintenanceTypesDescription: 'Tipos de sistema e suas tarefas de manutenção personalizadas',
+    addCustomType: 'Adicionar Tipo Personalizado',
+    restoreDefaults: 'Restaurar Tarefas Padrão',
+    intervalType: 'Tipo de Intervalo',
+    intervalValue: 'Intervalo ({{type}})',
+    icon: 'Icon',
+    documentationLink: 'Link da Documentação (opcional)',
+    assignToPrinters: 'Atribuir a Impressoras',
+    selectAtLeastOnePrinter: 'Selecione pelo menos uma impressora',
+    addType: 'Adicionar Tipo',
+    custom: 'Personalizado',
+    printHours: 'Horas de Impressão',
+    calendarDays: 'Dias de Calendário',
+    exampleName: 'ex., Substituir Filtro HEPA',
+    viewDocumentation: 'Ver documentação',
+    timeBasedInterval: 'Intervalo baseado em tempo',
+    // Interval overrides
+    intervalOverrides: 'Substituições de Intervalo',
+    intervalOverridesDescription: 'Personalize os intervalos para impressoras específicas',
+    // Printer assignment
+    assignedToPrinters: 'Atribuído a impressoras:',
+    noPrintersAssigned: 'Nenhuma impressora atribuída',
+    addPrinterShort: 'Adicionar:',
+    printersAssignedClick: '{{count}} impressora(s) atribuída(s) - clique para gerenciar',
+    removeFromPrinter: 'Remover desta impressora',
+    // Types
+    types: {
+      lubricateCarbonRods: 'Lubricar Barras de Carbono',
+      lubricateRails: 'Lubricar Trilhos Lineares',
+      cleanNozzle: 'Limpar Bico/Hotend',
+      checkBelts: 'Verificar Tensão das Correias',
+      cleanBuildPlate: 'Limpar Plataforma de Impressão',
+      checkExtruder: 'Verificar Engrenagens do Extrusor',
+      checkCooling: 'Verificar Ventiladores de Resfriamento',
+      generalInspection: 'Inspeção Geral',
+      cleanCarbonRods: 'Limpar Barras de Carbono',
+      cleanLinearRails: 'Limpar Trilhos Lineares',
+      checkPtfeTube: 'Verificar Tubo PTFE',
+      replaceHepaFilter: 'Substituir Filtro HEPA',
+      replaceCarbonFilter: 'Substituir Filtro de Carbono',
+      lubricateLeftNozzleRail: 'Lubrificar Trilho do Bico Esquerdo',
+    },
+    // Toast
+    maintenanceComplete: 'Manutenção marcada como concluída',
+    typeUpdated: 'Tipo de manutenção atualizado',
+    typeDeleted: 'Tipo de manutenção excluído',
+    defaultsRestored: 'Restauradas {{count}} tarefa(s) padrão',
+    printHoursUpdated: 'Horas de impressão atualizadas',
+    printerAssigned: 'Impressora atribuída',
+    printerRemoved: 'Impressora removida',
+    // Confirmation
+    deleteTypeConfirm: 'Excluir "{{name}}"?',
+    deleteSystemTypeTitle: 'Excluir tarefa de manutenção padrão?',
+    deleteSystemTypeMessage: 'Tem certeza de que deseja excluir a tarefa de manutenção padrão "{{name}}"?',
+    // Permissions
+    noPermissionUpdate: 'Você não tem permissão para atualizar itens de manutenção',
+    noPermissionPerform: 'Você não tem permissão para realizar manutenção',
+    noPermissionEditTypes: 'Você não tem permissão para editar tipos de manutenção',
+    noPermissionDeleteTypes: 'Você não tem permissão para excluir tipos de manutenção',
+    noPermissionEditHours: 'Você não tem permissão para editar horas de impressão',
+    noPermissionRemovePrinter: 'Você não tem permissão para remover atribuições de impressora',
+    noPermissionAssignPrinter: 'Você não tem permissão para atribuir impressoras',
+    noPermissionEditIntervals: 'Você não tem permissão para editar intervalos',
+    // Configure link
+    configureSettings: 'Configure tipos de manutenção e intervalos',
+  },
+
+  // Settings page
+  settings: {
+    title: 'Configurações',
+    general: 'Geral',
+    // Tab names
+    tabs: {
+      general: 'Geral',
+      smartPlugs: 'Tomadas Inteligentes',
+      notifications: 'Notificações',
+      filament: 'Filamento',
+      network: 'Rede',
+      apiKeys: 'Chaves API',
+      virtualPrinter: 'Impressora Virtual',
+      users: 'Autenticação',
+      backup: 'Backup',
+      emailAuth: 'Autenticação por Email',
+    },
+    // Email settings
+    email: {
+      smtpSettings: 'Configuração SMTP',
+      smtpHost: 'Servidor SMTP',
+      smtpPort: 'Porta SMTP',
+      security: 'Segurança',
+      authentication: 'Autenticação',
+      username: 'Nome de Usuário',
+      password: 'Senha',
+      fromEmail: 'Email de Remetente',
+      fromName: 'Nome de Remetente',
+      testConnection: 'Testar Conexão SMTP',
+      testRecipient: 'Email de Teste',
+      sendTest: 'Enviar Email de Teste',
+      sending: 'Enviando...',
+      save: 'Salvar Configurações',
+      saving: 'Salvando...',
+      advancedAuth: 'Autenticação Avançada',
+      advancedAuthEnabled: 'Autenticação Avançada está habilitada',
+      advancedAuthEnabledDesc: 'Recursos de gerenciamento de usuários baseados em email estão ativos. Novos usuários receberão senhas geradas automaticamente por email, e os usuários podem redefinir suas senhas através do recurso de esqueci minha senha.',
+      advancedAuthDisabled: 'Autenticação Avançada está desabilitada',
+      advancedAuthDisabledDesc: 'Habilite a autenticação avançada para ativar recursos baseados em email para gerenciamento de usuários.',
+      enable: 'Habilitar',
+      disable: 'Desabilitar',
+      feature1: 'Senhas são geradas automaticamente e enviadas por email para novos usuários',
+      feature2: 'Usuários podem fazer login com nome de usuário ou email',
+      feature3: 'Recurso de esqueci minha senha está disponível',
+      feature4: 'Administradores podem redefinir senhas de usuários via email',
+      // Error messages
+      errors: {
+        requiredFields: 'Por favor, preencha todos os campos obrigatórios',
+        usernameRequired: 'Nome de usuário é obrigatório quando a autenticação está habilitada',
+        enterTestEmail: 'Por favor, insira um endereço de email de teste',
+        smtpServerAndEmail: 'Por favor, preencha o servidor SMTP e o email de remetente antes de testar',
+        usernamePasswordRequired: 'Nome de usuário e senha são obrigatórios quando a autenticação está habilitada',
+        configureSmtpFirst: 'Por favor, configure e teste as configurações SMTP primeiro',
+      },
+      // Success messages
+      success: {
+        settingsSaved: 'Configurações SMTP salvas com sucesso',
+      },
+      // Security options
+      securityOptions: {
+        starttls: 'STARTTLS (Porta 587)',
+        ssl: 'SSL/TLS (Porta 465)',
+        none: 'Nenhuma (Porta 25)',
+      },
+      // Authentication options
+      authOptions: {
+        enabled: 'Habilitado',
+        disabled: 'Desabilitado',
+      },
+    },
+    appearance: 'Aparência',
+    notifications: 'Notificações',
+    smartPlugs: 'Tomadas Inteligentes',
+    spoolman: 'Spoolman',
+    updates: 'Atualizações',
+    language: 'Idioma',
+    languageDescription: 'Selecione seu idioma preferido',
+    theme: 'Tema',
+    themeLight: 'Claro',
+    themeDark: 'Escuro',
+    themeSystem: 'Sistema',
+    defaultView: 'Visualização Padrão',
+    defaultViewDescription: 'Página a ser exibida ao abrir o aplicativo',
+    checkForUpdates: 'Verificar Atualizações',
+    autoUpdate: 'Atualização Automática',
+    currentVersion: 'Versão Atual',
+    latestVersion: 'Última Versão',
+    upToDate: 'Você está atualizado',
+    updateAvailable: 'Atualização disponível',
+    // Notifications
+    notificationLanguage: 'Idioma das Notificações',
+    notificationLanguageDescription: 'Idioma para notificações push',
+    bedCooledThreshold: 'Limite de Resfriamento da Cama',
+    bedCooledThresholdDescription: 'Temperatura abaixo da qual a cama é considerada resfriada após uma impressão',
+    notificationProviders: 'Provedores de Notificação',
+    addProvider: 'Adicionar Provedor',
+    editProvider: 'Editar Provedor',
+    providerType: 'Tipo de Provedor',
+    testNotification: 'Testar Notificação',
+    testSuccess: 'Notificação de teste enviada com sucesso',
+    testFailed: 'Falha ao enviar notificação de teste',
+    quietHours: 'Horas de Silêncio',
+    quietHoursDescription: 'Não perturbe durante essas horas',
+    quietHoursStart: 'Início',
+    quietHoursEnd: 'Fim',
+    events: {
+      title: 'Eventos de Notificação',
+      printStart: 'Impressão Iniciada',
+      printComplete: 'Impressão Concluída',
+      printFailed: 'Falha na Impressão',
+      printStopped: 'Impressão Interrompida',
+      printProgress: 'Marcos de Progresso',
+      printProgressDescription: 'Notificar em 25%, 50%, 75%',
+      printerOffline: 'Impressora Offline',
+      printerError: 'Erro na Impressora',
+      filamentLow: 'Filamento Baixo',
+      maintenanceDue: 'Manutenção Pendente',
+      maintenanceDueDescription: 'Notificar quando a manutenção for necessária',
+    },
+    // Smart Plugs
+    smartPlug: {
+      title: 'Tomadas Inteligentes',
+      add: 'Adicionar Tomada Inteligente',
+      edit: 'Editar Tomada Inteligente',
+      name: 'Nome',
+      ipAddress: 'Endereço IP',
+      linkedPrinter: 'Impressora Vinculada',
+      autoOn: 'Ligar Automaticamente',
+      autoOnDescription: 'Ligar quando a impressão começar',
+      autoOff: 'Desligar Automaticamente',
+      autoOffDescription: 'Desligar após a conclusão da impressão',
+      offDelay: 'Atraso para Desligar',
+      offDelayMinutes: 'Minutos após a impressão',
+      offDelayTemp: 'Quando o bico estiver abaixo da temperatura',
+      currentState: 'Estado Atual',
+      turnOn: 'Ligar',
+      turnOff: 'Desligar',
+    },
+    // Filament Tracking Mode
+    filamentTracking: 'Rastreamento de Filamento',
+    filamentTrackingDesc: 'Escolha como rastrear seus rolos de filamento. Você pode usar o inventário interno ou conectar a um servidor Spoolman externo.',
+    trackingModeBuiltIn: 'Inventário Interno',
+    trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos',
+    trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo',
+    builtInFeatureRfid: 'Detecta automaticamente rolos RFID da Bambu Lab no AMS',
+    builtInFeatureUsage: 'Rastreia o consumo de filamento por impressão',
+    builtInFeatureCatalog: 'Gerencia rolos, cores e perfis de fator K',
+    builtInFeatureThirdParty: 'Rolos de terceiros podem ser atribuídos aos rolos do inventário',
+    amsSyncButton: 'Sincronizar Pesos do AMS',
+    amsSyncTitle: 'Sincronizar Pesos dos Rolos do AMS',
+    amsSyncMessage: 'Isso substituirá todos os pesos dos rolos do inventário pelos valores atuais de % restante do AMS das impressoras conectadas. Use isso para recuperar dados de peso corrompidos. As impressoras devem estar online.',
+    amsSyncing: 'Sincronizando...',
+    amsSyncSuccess: '{{synced}} rolo(s) sincronizado(s), {{skipped}} ignorado(s)',
+    amsSyncError: 'Falha ao sincronizar pesos do AMS',
+    // Spoolman settings
+    spoolmanUrl: 'Spoolman URL',
+    spoolmanUrlHint: 'URL do seu servidor Spoolman (por exemplo, http://localhost:7912)',
+    spoolmanConnected: 'Conectado',
+    spoolmanDisconnected: 'Desconectado',
+    status: 'Status',
+    connect: 'Conectar',
+    disconnect: 'Desconectar',
+    howSyncWorks: 'Como a Sincronização Funciona',
+    syncInfoRfidOnly: 'Apenas rolos oficiais da Bambu Lab com RFID são sincronizados',
+    syncInfoAutoCreate: 'Novos rolos são criados automaticamente no Spoolman na primeira sincronização',
+    syncInfoThirdPartySkipped: 'Rolos não oficiais da Bambu Lab (terceiros, reabastecidos) são ignorados',
+    linkingExistingSpools: 'Vinculando Rolos Existentes',
+    linkingExistingSpoolsDesc: 'Para vincular rolos existentes do Spoolman ao seu AMS, passe o mouse sobre um slot do AMS e clique em "Vincular ao Spoolman".',
+    syncMode: 'Modo de Sincronização',
+    syncModeAuto: 'Automático',
+    syncModeManual: 'Apenas Manual',
+    syncModeAutoDesc: 'Os dados do AMS são sincronizados automaticamente quando alterações são detectadas',
+    syncModeManualDesc: 'Somente sincronize quando acionado manualmente',
+    syncAmsData: 'Sincronizar Dados do AMS',
+    syncAmsDataDesc: 'Sincronize manualmente os dados do AMS da impressora com o Spoolman',
+    allPrinters: 'Todas as Impressoras',
+    // Default printer
+    noDefaultPrinter: 'Sem padrão (perguntar a cada vez)',
+    // Sidebar
+    sidebarOrder: 'Ordem da barra lateral',
+    // Camera
+    saveThumbnails: 'Salvar miniaturas',
+    captureFinishPhoto: 'Capturar foto de conclusão',
+    noPrintersConfigured: 'Nenhuma impressora configurada',
+    // Archive settings
+    archiveMode: {
+      always: 'Sempre criar entrada de arquivo',
+      never: 'Nunca criar entrada de arquivo',
+      ask: 'Perguntar a cada vez',
+    },
+    // Updates
+    checkForUpdatesLabel: 'Verificar atualizações',
+    checkPrinterFirmware: 'Verificar firmware da impressora',
+    // Queue
+    enableRetry: 'Habilitar tentativa',
+    // Home Assistant
+    homeAssistantDescription: 'Controlar tomadas inteligentes via Home Assistant',
+    environmentManagedLabel: '(Gerenciado pelo Ambiente)',
+    autoEnabledViaEnv: 'Habilitado automaticamente via variáveis de ambiente',
+    urlFromEnvReadOnly: 'Valor definido pela variável de ambiente HA_URL (somente leitura)',
+    tokenFromEnvReadOnly: 'Valor definido pela variável de ambiente HA_TOKEN (somente leitura)',
+    // MQTT
+    mqttConnectedTo: 'Conectado a',
+    // Prometheus
+    prometheusDescription: 'Expor dados da impressora no formato Prometheus',
+    // Smart plugs empty state
+    noSmartPlugsTitle: 'Nenhuma tomada inteligente configurada',
+    noSmartPlugsDescription: 'Adicione uma tomada inteligente baseada em Tasmota para monitorar o consumo de energia e automatizar o controle de energia.',
+    // Notifications empty state
+    noProvidersTitle: 'Nenhum provedor configurado',
+    noProvidersDescription: 'Adicione um provedor para receber alertas.',
+    noTemplatesAvailable: 'Nenhum modelo disponível. Reinicie o backend para gerar os modelos padrão.',
+    // API permissions
+    apiPermissionView: 'Visualizar status da impressora e fila',
+    apiPermissionEdit: 'Adicionar e remover itens da fila de impressão',
+    // API keys
+    apiKeysEmptyTitle: 'Nenhuma chave API',
+    apiKeysEmptyDescription: 'Crie uma chave API para integrar com serviços externos.',
+    // Users
+    noUsersFound: 'Nenhum usuário encontrado',
+    noGroupsFound: 'Nenhum grupo encontrado',
+    noGroupsAvailable: 'Nenhum grupo disponível',
+    passwordsDoNotMatch: 'As senhas não coincidem',
+    systemGroupWarning: 'Os nomes dos grupos do sistema não podem ser alterados',
+    // Auth disabled
+    authDisabledTitle: 'Autenticação Desativada',
+    authDisabledFeature1: 'Exigir login para acessar o sistema',
+    authDisabledFeature2: 'Criar múltiplos usuários com permissões baseadas em grupos',
+    authDisabledFeature3: 'Controlar acesso com mais de 50 permissões granulares',
+    // User deletion
+    userHasCreated: 'Este usuário criou:',
+    userItemsQuestion: 'O que você gostaria de fazer com esses itens?',
+    deleteUserConfirm: 'Tem certeza de que deseja excluir este usuário?',
+    actionCannotBeUndone: 'Esta ação não pode ser desfeita.',
+    // Smart plugs
+    addFirstSmartPlug: 'Adicione sua primeira tomada inteligente',
+    // Notifications
+    providers: 'Provedores',
+    log: 'Registro',
+    testAll: 'Testar tudo',
+    testResults: 'Resultados do teste',
+    testPassedCount: '{{count}} aprovado',
+    testFailedCount: '{{count}} falhou',
+    messageTemplates: 'Modelos de mensagem',
+    messageTemplatesDescription: 'Personalize as mensagens de notificação para cada evento.',
+    // API Keys section
+    apiKeys: 'Chaves API',
+    apiKeysDescription: 'Crie chaves API para integrações externas e webhooks.',
+    createKey: 'Criar Chave',
+    apiKeyCreated: 'Chave API criada com sucesso',
+    apiKeyCopyWarning: "Copie esta chave agora - ela não será exibida novamente!",
+    useInApiBrowser: 'Usar no Navegador API',
+    createNewApiKey: 'Criar Nova Chave API',
+    keyName: 'Nome da Chave',
+    keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',
+    readStatus: 'Status de Leitura',
+    readStatusDescription: 'Visualizar status da impressora e fila',
+    manageQueue: 'Gerenciar Fila',
+    manageQueueDescription: 'Adicionar e remover itens da fila de impressão',
+    controlPrinter: 'Controlar Impressora',
+    controlPrinterDescription: 'Pausar, retomar e parar impressões',
+    unnamedKey: 'Chave Sem Nome',
+    lastUsed: 'Último uso',
+    read: 'Ler',
+    control: 'Controlar',
+    createFirstKey: 'Crie sua primeira chave',
+    webhookEndpoints: 'Endpoints de Webhook',
+    webhookApiKeyHint: 'Use sua chave API no cabeçalho X-API-Key.',
+    webhook: {
+      getAllStatus: 'Obter status de todas as impressoras',
+      getSpecificStatus: 'Obter status de uma impressora específica',
+      addToQueue: 'Adicionar à fila de impressão',
+      pausePrint: 'Pausar impressão',
+      resumePrint: 'Retomar impressão',
+      stopPrint: 'Parar impressão',
+    },
+    apiBrowser: 'Navegador API',
+    apiBrowserDescription: 'Explore e teste todos os endpoints de API disponíveis.',
+    apiKeyForTesting: 'Chave API para Teste',
+    apiKeyPlaceholder: 'Cole sua chave API aqui para testar endpoints autenticados...',
+    apiKeyHint: 'Esta chave será enviada como cabeçalho X-API-Key nas solicitações.',
+    deleteApiKeyTitle: 'Excluir Chave API',
+    deleteApiKeyMessage: 'Tem certeza de que deseja excluir esta chave API? Quaisquer integrações usando esta chave deixarão de funcionar.',
+    deleteKey: 'Excluir Chave',
+    // Filament tab
+    amsDisplayThresholds: 'Limiares de Exibição AMS',
+    amsThresholdsDescription: 'Configure os limiares de cores para os indicadores de umidade e temperatura do AMS.',
+    humidity: 'Umidade',
+    goodGreen: 'Bom (verde)',
+    fairOrange: 'Razoável (laranja)',
+    aboveFairBad: 'Acima do limiar razoável mostra como vermelho (ruim)',
+    temperature: 'Temperatura',
+    goodBlue: 'Bom (azul)',
+    aboveFairHot: 'Acima do limiar razoável mostra como vermelho (quente)',
+    historyRetention: 'Retenção de Histórico',
+    keepSensorHistory: 'Manter histórico do sensor por',
+    historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',
+    printModal: 'Modal de Impressão',
+    expandCustomMapping: 'Expandir mapeamento personalizado por padrão',
+    expandCustomMappingDescription: 'Ao imprimir em várias impressoras, mostrar o mapeamento AMS por impressora expandido',
+    // User management
+    authentication: 'Autenticação',
+    authEnabledDescription: 'Sua instância está protegida com autenticação de usuário',
+    authDisabledDescription: 'Ative para exigir login e gerenciar o acesso dos usuários',
+    authDisabledMessage: 'Ative a autenticação para criar contas de usuário, gerenciar permissões e proteger sua instância do Bambuddy.',
+    enableAuthentication: 'Ativar Autenticação',
+    currentUser: 'Usuário Atual',
+    changePassword: 'Alterar Senha',
+    admin: 'Administrador',
+    users: 'Usuários',
+    addUser: 'Adicionar Usuário',
+    groups: 'Grupos',
+    addGroup: 'Adicionar Grupo',
+    system: 'Sistema',
+    noDescription: 'Sem descrição',
+    userCount: '{{count}} usuários',
+    permissionCount: '{{count}} permissões',
+    createUser: 'Criar Usuário',
+    username: 'Nome de Usuário',
+    enterUsername: 'Digite o nome de usuário',
+    password: 'Senha',
+    enterPassword: 'Digite a senha (mínimo 6 caracteres)',
+    confirmPassword: 'Confirmar Senha',
+    confirmPasswordPlaceholder: 'Confirme a senha',
+    // Title tooltips
+    viewReleaseOnGitHub: 'Ver lançamento no GitHub',
+    turnAllPlugsOn: 'Ligar todas as tomadas',
+    turnAllPlugsOff: 'Desligar todas as tomadas',
+    // Modal: Clear logs
+    clearNotificationLogs: 'Limpar Logs de Notificação',
+    clearLogsMessage: 'Isso excluirá permanentemente todos os logs de notificação com mais de 30 dias. Esta ação não pode ser desfeita.',
+    clearLogs: 'Limpar Logs',
+    // Modal: Reset UI
+    resetUiPreferences: 'Redefinir Preferências de UI',
+    resetUiPreferencesMessage: 'Isso redefinirá todas as preferências de UI para os padrões: ordem da barra lateral, tema, layout do painel, modos de exibição e preferências de classificação. Suas impressoras, arquivos e configurações do servidor NÃO serão afetados. A página será recarregada após a limpeza.',
+    resetPreferences: 'Redefinir Preferências',
+    // Modal: Delete group
+    deleteGroupTitle: 'Excluir Grupo',
+    deleteGroupMessage: 'Tem certeza de que deseja excluir este grupo? Usuários neste grupo perderão essas permissões.',
+    deleteGroup: 'Excluir Grupo',
+    // Modal: Disable auth
+    disableAuthenticationTitle: 'Desativar Autenticação',
+    disableAuthenticationMessage: 'Tem certeza de que deseja desativar a autenticação? Isso tornará sua instância do Bambuddy acessível sem login. Todos os usuários permanecerão no banco de dados, mas a autenticação será desativada.',
+    disableAuthentication: 'Desativar Autenticação',
+    // Additional settings
+    configureBambuddy: 'Configurar Bambuddy',
+    systemDefault: 'Padrão do Sistema',
+    archiveSettings: 'Configurações de Arquivo',
+    newWindow: 'Nova Janela',
+    embeddedOverlay: 'Sobreposição Incorporada',
+    preferredSlicer: 'Fatiador Preferido',
+    preferredSlicerDescription: 'Escolha qual aplicativo de fatiamento abrirá os arquivos',
+    externalCameras: 'Câmeras Externas',
+    costTracking: 'Rastreamento de Custos',
+    printsOnly: 'Apenas Impressões',
+    totalConsumption: 'Consumo Total',
+    dataManagement: 'Gerenciamento de Dados',
+    storageUsage: 'Uso de Armazenamento',
+    storageUsageDescription: 'Detalhamento do uso de dados por categoria',
+    storageUsageTotal: 'Total',
+    storageUsageErrors: 'Erros',
+    storageUsageOtherBreakdown: 'Outros (inclui ativos estáticos, scripts e arquivos de configuração)',
+    storageUsageSystem: 'Sistema',
+    storageUsageData: 'Dados',
+    storageUsageUnavailable: 'Informações de uso de armazenamento indisponíveis',
+    clearNotificationLogsDescription: 'Excluir logs de notificação com mais de 30 dias',
+    resetUiPreferencesDescription: 'Redefinir ordem da barra lateral, tema, modos de exibição e preferências de layout. Impressoras, arquivos e configurações não são afetados.',
+    enableHomeAssistant: 'Ativar Home Assistant',
+    enableMqtt: 'Ativar MQTT',
+    useTls: 'Usar TLS',
+    enableMetricsEndpoint: 'Ativar Endpoint de Métricas',
+    availableMetrics: 'Métricas Disponíveis',
+    editUser: 'Editar Usuário',
+    deleteUserTitle: 'Excluir Usuário',
+    groupName: 'Nome do Grupo',
+    // Placeholders
+    leaveEmptyForAnonymous: 'Deixe vazio para anônimo',
+    leaveEmptyForNoAuth: 'Deixe vazio para sem autenticação',
+    enterNewPassword: 'Digite a nova senha',
+    confirmNewPassword: 'Confirme a nova senha',
+    enterGroupName: 'Digite o nome do grupo',
+    enterDescriptionOptional: 'Digite a descrição (opcional)',
+    enterCurrentPassword: 'Digite a senha atual',
+    enterNewPasswordMin6: 'Digite a nova senha (mínimo 6 caracteres)',
+    toast: {
+      keyCopied: 'Chave copiada para a área de transferência',
+      copyFailed: 'Falha ao copiar a chave',
+      keyAddedToBrowser: 'Chave adicionada ao Navegador de API',
+      clearLogsFailed: 'Falha ao limpar logs',
+      uiPreferencesReset: 'Preferências de UI redefinidas. Atualizando...',
+      authDisabled: 'Autenticação desativada com sucesso',
+      authDisableFailed: 'Falha ao desativar a autenticação',
+      apiKeyCreated: 'Chave de API criada',
+      apiKeyDeleted: 'Chave de API excluída',
+      userCreated: 'Usuário criado com sucesso',
+      userUpdated: 'Usuário atualizado com sucesso',
+      userDeleted: 'Usuário excluído com sucesso',
+      groupCreated: 'Grupo criado com sucesso',
+      groupUpdated: 'Grupo atualizado com sucesso',
+      groupDeleted: 'Grupo excluído com sucesso',
+      fillRequiredFields: 'Por favor, preencha todos os campos obrigatórios',
+      passwordsDoNotMatch: 'As senhas não coincidem',
+      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+      enterGroupName: 'Por favor, insira um nome de grupo',
+      settingsSaved: 'Configurações salvas',
+      cameraSettingsSaved: 'Configurações da câmera salvas',
+      enterCameraUrl: 'Por favor, insira a URL da câmera',
+      passwordChanged: 'Senha alterada com sucesso',
+      connectionFailed: 'Falha na conexão',
+      testFailed: 'Falha no teste',
+      cameraConnected: 'Câmera conectada{{resolution}}',
+    },
+    testConnection: 'Testar Conexão',
+    catalog: {
+      spoolCatalog: 'Catálogo de Carretéis',
+      spoolCatalogDescription: 'Pesos de carretéis vazios por marca/tipo. Usado para pesquisa automática de peso ao adicionar carretéis.',
+      searchCatalog: 'Pesquisar no catálogo...',
+      addNewEntry: 'Adicionar Nova Entrada',
+      namePlaceholder: 'Nome (ex.: Bambu Lab - Plástico)',
+      weight: 'Peso',
+      type: 'Tipo',
+      default: 'Padrão',
+      custom: 'Personalizado',
+      noMatch: 'Nenhuma entrada corresponde à sua pesquisa',
+      empty: 'Nenhuma entrada no catálogo',
+      deleteEntry: 'Excluir Entrada',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+      resetCatalog: 'Redefinir Catálogo',
+      resetConfirm: 'Redefinir catálogo para os padrões? Isso removerá todas as entradas personalizadas.',
+      loadFailed: 'Falha ao carregar o catálogo de carretéis',
+      nameWeightRequired: 'Nome e peso são obrigatórios',
+      entryAdded: 'Entrada adicionada',
+      addFailed: 'Falha ao adicionar entrada',
+      entryUpdated: 'Entrada atualizada',
+      updateFailed: 'Falha ao atualizar entrada',
+      entryDeleted: 'Entrada excluída',
+      deleteFailed: 'Falha ao excluir entrada',
+      resetSuccess: 'Catálogo redefinido para os padrões',
+      resetFailed: 'Falha ao redefinir catálogo',
+      exported: 'Exportadas {{count}} entradas',
+      imported: 'Importadas {{added}} entradas ({{skipped}} ignoradas)',
+      importFailed: 'Falha ao importar: formato JSON inválido',
+      exportTooltip: 'Exportar catálogo para JSON',
+      importTooltip: 'Importar catálogo de JSON',
+      resetTooltip: 'Redefinir para os padrões',
+    },
+    colorCatalog: {
+      title: 'Catálogo de Cores',
+      description: 'Cores de filamento por fabricante/material. Usado para pesquisa automática de cores ao adicionar carretéis.',
+      searchColors: 'Pesquisar cores...',
+      allManufacturers: 'Todos os fabricantes',
+      addNewColor: 'Adicionar Nova Cor',
+      manufacturer: 'Fabricante',
+      colorName: 'Nome da Cor',
+      hex: 'Hex',
+      materialOptional: 'Material (opcional)',
+      showing: 'Mostrando {{filtered}} de {{total}} cores',
+      noMatch: 'Nenhuma cor corresponde à sua pesquisa',
+      empty: 'Nenhuma cor no catálogo',
+      deleteColor: 'Excluir Cor',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+      resetCatalog: 'Redefinir Catálogo de Cores',
+      resetConfirm: 'Redefinir catálogo para os padrões? Isso removerá todas as cores personalizadas.',
+      sync: 'Sincronizar',
+      starting: 'Iniciando...',
+      syncTooltip: 'Sincronizar do FilamentColors.xyz (2000+ cores, pode levar um minuto)',
+      loadFailed: 'Falha ao carregar o catálogo de cores',
+      fieldsRequired: 'Fabricante, nome da cor e cor hex são obrigatórios',
+      colorAdded: 'Cor adicionada',
+      addFailed: 'Falha ao adicionar cor',
+      colorUpdated: 'Cor atualizada',
+      updateFailed: 'Falha ao atualizar cor',
+      colorDeleted: 'Cor excluída',
+      deleteFailed: 'Falha ao excluir cor',
+      resetSuccess: 'Catálogo de cores redefinido para os padrões',
+      resetFailed: 'Falha ao redefinir catálogo',
+      syncUpToDate: 'Já está atualizado ({{count}} cores verificadas)',
+      syncComplete: 'Adicionadas {{added}} novas cores ({{skipped}} já existiam)',
+      syncError: 'Erro de sincronização',
+      syncFailed: 'Falha ao sincronizar do FilamentColors.xyz',
+      exported: 'Exportadas {{count}} cores',
+      imported: 'Importadas {{added}} cores ({{skipped}} ignoradas)',
+      importFailed: 'Falha ao importar: formato JSON inválido',
+    },
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: 'Impressão Iniciada',
+      body: '{{printer}}: {{filename}} iniciou a impressão',
+    },
+    printCompleted: {
+      title: 'Impressão Concluída',
+      body: '{{printer}}: {{filename}} foi concluída com sucesso',
+    },
+    printFailed: {
+      title: 'Falha na Impressão',
+      body: '{{printer}}: {{filename}} falhou',
+    },
+    printStopped: {
+      title: 'Impressão Interrompida',
+      body: '{{printer}}: {{filename}} foi interrompida',
+    },
+    printProgress: {
+      title: 'Progresso da Impressão',
+      body: '{{printer}}: {{filename}} está {{percent}}% concluída',
+    },
+    printerOffline: {
+      title: 'Impressora Offline',
+      body: '{{printer}} está offline',
+    },
+    printerError: {
+      title: 'Erro na Impressora',
+      body: '{{printer}}: {{error}}',
+    },
+    filamentLow: {
+      title: 'Filamento Baixo',
+      body: '{{printer}}: O filamento está acabando',
+    },
+    maintenanceDue: {
+      title: 'Manutenção Pendente',
+      body: '{{printer}}: {{items}} precisam de atenção',
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: 'Algo deu errado',
+    networkError: 'Erro de rede. Por favor, verifique sua conexão.',
+    notFound: 'Não encontrado',
+    unauthorized: 'Não autorizado',
+    serverError: 'Erro no servidor',
+    validationError: 'Por favor, verifique sua entrada',
+    printerConnectionFailed: 'Falha ao conectar à impressora',
+    saveFailed: 'Falha ao salvar alterações',
+    deleteFailed: 'Falha ao excluir',
+    loadFailed: 'Falha ao carregar dados',
+  },
+
+  // HMS Errors modal
+  hmsErrors: {
+    title: 'Erros - {{name}}',
+    noErrors: 'Nenhum erro',
+    viewOnWiki: 'Ver no Bambu Lab Wiki',
+    clearInstructions: 'Limpe os erros na impressora para descartá-los aqui.',
+    clearErrors: 'Limpar Erros',
+    clearSuccess: 'Erros HMS limpos',
+    clearFailed: 'Falha ao limpar erros HMS',
+  },
+
+  // MQTT Debug modal
+  mqttDebug: {
+    title: 'MQTT Log de Depuração',
+    searchPlaceholder: 'Pesquisar tópico ou payload...',
+    noMessages: 'Nenhuma mensagem registrada ainda',
+    startLoggingHint: 'Clique em "Iniciar Registro" para começar a capturar mensagens MQTT',
+    noMessagesMatch: 'Nenhuma mensagem corresponde ao seu filtro',
+    adjustFilterHint: 'Tente ajustar seus critérios de pesquisa ou filtro',
+    incoming: 'Entrada',
+    outgoing: 'Saída',
+    loggingStopped: 'Registro interrompido',
+    loggingActive: 'Registro ativo - as mensagens serão atualizadas automaticamente',
+    startLogging: 'Iniciar Registro',
+    stopLogging: 'Parar Registro',
+    clearLog: 'Limpar Registro',
+    topic: 'ópico',
+    timestamp: 'Carimbo de Data/Hora',
+    direction: 'Direção',
+    all: 'Todos',
+  },
+
+  // Printer File Manager modal (printer internal storage)
+  printerFiles: {
+    title: 'Gerenciador de Arquivos',
+    storageUsed: 'Usado:',
+    storageFree: 'Livre:',
+    filterPlaceholder: 'Filtrar arquivos...',
+    deleteButton: 'Excluir',
+    deleteFiles: 'Excluir {{count}} arquivos',
+    deleteFileConfirm: 'Excluir "{{name}}"? Isso não pode ser desfeito.',
+    deleteFilesConfirm: 'Excluir {{count}} arquivos selecionados? Isso não pode ser desfeito.',
+    noFiles: 'Nenhum arquivo na impressora',
+    loadingFiles: 'Carregando arquivos...',
+    failedToLoad: 'Falha ao carregar arquivos',
+    toast: {
+      filesDeleted: 'Arquivos excluídos: {{count}}',
+      deleteFailed: 'Falha ao excluir: {{error}}',
+    },
+  },
+
+  // Confirmations
+  confirm: {
+    delete: 'Tem certeza de que deseja excluir isso?',
+    unsavedChanges: 'Você tem alterações não salvas. Tem certeza de que deseja sair?',
+    clearQueue: 'Tem certeza de que deseja limpar a fila?',
+  },
+
+  // Login page
+  login: {
+    title: 'Bambuddy Login',
+    subtitle: 'Faça login na sua conta',
+    username: 'Nome de usuário',
+    usernamePlaceholder: 'Digite seu nome de usuário',
+    usernameOrEmail: 'Nome de usuário ou Email',
+    usernameOrEmailPlaceholder: 'Nome de usuário ou Email',
+    password: 'Senha',
+    passwordPlaceholder: 'Digite sua senha',
+    signIn: 'Entrar',
+    signingIn: 'Entrando...',
+    forgotPassword: 'Esqueceu sua senha?',
+    loginSuccess: 'Login realizado com sucesso',
+    loginFailed: 'Falha no login',
+    enterCredentials: 'Por favor, insira nome de usuário e senha',
+    forgotPasswordTitle: 'Esqueceu a Senha',
+    forgotPasswordMessage: "Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.",
+    forgotPasswordEmailMessage: "Digite seu endereço de email e enviaremos uma nova senha.",
+    emailAddress: 'Endereço de Email',
+    emailPlaceholder: 'seu.email@exemplo.com',
+    cancel: 'Cancelar',
+    sending: 'Enviando...',
+    sendResetEmail: 'Enviar Email de Redefinição',
+    howToReset: 'Como redefinir sua senha:',
+    resetStep1: 'Entre em contato com o administrador do Bambuddy',
+    resetStep2: 'Peça para redefinir sua senha na Gestão de Usuários',
+    resetStep3: 'Eles podem definir uma nova senha temporária para você',
+    resetStep4: 'Faça login com a nova senha e altere-a nas Configurações',
+    gotIt: 'Entendi',
+  },
+
+  // Setup page
+  setup: {
+    title: 'Bambuddy Configuração',
+    subtitle: 'Configure a autenticação para sua instância do Bambuddy',
+    enableAuth: 'Ativar Autenticação',
+    adminAccount: 'Conta de Administrador',
+    adminAccountDesc: 'Se usuários administradores já existirem, a autenticação será ativada usando as contas de administrador existentes. Deixe os campos abaixo vazios para usar os administradores existentes ou insira novas credenciais para criar um novo usuário administrador.',
+    adminUsername: 'Nome de usuário do administrador',
+    adminPassword: 'Senha do administrador',
+    optionalIfAdminExists: '(opcional se usuários administradores existirem)',
+    adminUsernamePlaceholder: 'Digite o nome de usuário do administrador (opcional)',
+    adminPasswordPlaceholder: 'Digite a senha do administrador (opcional)',
+    confirmPassword: 'Confirmar Senha',
+    confirmPasswordPlaceholder: 'Confirme a senha do administrador',
+    settingUp: 'Configurando...',
+    completeSetup: 'Concluir Configuração',
+    toast: {
+      authEnabledAdminCreated: 'Autenticação ativada e usuário administrador criado',
+      authEnabledExistingAdmins: 'Autenticação ativada usando usuários administradores existentes',
+      setupCompleted: 'Configuração concluída',
+      enterBothCredentials: 'Por favor, insira o nome de usuário e a senha do administrador, ou deixe ambos vazios para usar os usuários administradores existentes',
+      passwordsDoNotMatch: 'As senhas não coincidem',
+      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+    },
+  },
+
+  // Password change
+  changePassword: {
+    title: 'Alterar Senha',
+    currentPassword: 'Senha Atual',
+    currentPasswordPlaceholder: 'Digite a senha atual',
+    newPassword: 'Nova Senha',
+    newPasswordPlaceholder: 'Digite a nova senha (mínimo 6 caracteres)',
+    confirmPassword: 'Confirmar Senha',
+    confirmPasswordPlaceholder: 'Confirme a nova senha',
+    passwordsDoNotMatch: 'As senhas não coincidem',
+    passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+    changing: 'Alterando...',
+    success: 'Senha alterada com sucesso',
+    failed: 'Falha ao alterar a senha',
+  },
+
+  // Plate detection alert
+  plateAlert: {
+    title: 'Impressão Pausada!',
+    message: 'Objetos detectados na mesa de impressão. A impressão foi automaticamente pausada. Por favor, limpe a mesa e retome a impressão.',
+    understand: 'Entendi',
+  },
+
+  // Camera page
+  camera: {
+    title: 'Visualização da Câmera',
+    invalidPrinterId: 'ID da impressora inválido',
+    live: 'Ao Vivo',
+    snapshot: 'Captura',
+    restartStream: 'Reiniciar transmissão',
+    refreshSnapshot: 'Atualizar captura',
+    fullscreen: 'Tela Cheia',
+    exitFullscreen: 'Sair da Tela Cheia',
+    connectingToCamera: 'Conectando à câmera...',
+    capturingSnapshot: 'Capturando imagem...',
+    connectionLost: 'Conexão perdida',
+    connectionFailed: 'Falha na conexão com a câmera',
+    reconnecting: 'Reconectando em {{countdown}}s... (tentativa {{attempt}}/{{max}})',
+    reconnectNow: 'Reconectar agora',
+    cameraUnavailable: 'Câmera indisponível',
+    cameraUnavailableDesc: 'Certifique-se de que a impressora está ligada e conectada.',
+    noCamera: 'Nenhuma câmera disponível',
+    retry: 'Tentar novamente',
+    cameraStream: 'Transmissão da câmera',
+    zoomOut: 'Reduzir zoom',
+    zoomIn: 'Aumentar zoom',
+    resetZoom: 'Redefinir zoom',
+    recording: 'Gravando',
+    startRecording: 'Iniciar gravação',
+    stopRecording: 'Parar gravação',
+    chamberLight: 'Alternar luz da câmara',
+  },
+
+  // Groups management
+  groups: {
+    title: 'Gerenciamento de Grupos',
+    subtitle: 'Gerenciar grupos de permissão para controle de acesso',
+    backToSettings: 'Voltar para Configurações',
+    createGroup: 'Criar Grupo',
+    noPermission: 'Você não tem permissão para acessar esta página.',
+    system: 'Sistema',
+    noDescription: 'Sem descrição',
+    usersCount: '{{count}} usuários',
+    permissionsCount: '{{count}} permissões',
+    edit: 'Editar',
+    delete: 'Excluir',
+    toast: {
+      created: 'Grupo criado com sucesso',
+      updated: 'Grupo atualizado com sucesso',
+      deleted: 'Grupo excluído com sucesso',
+      enterGroupName: 'Por favor, insira um nome para o grupo',
+    },
+    modal: {
+      editGroup: 'Editar Grupo',
+      createGroup: 'Criar Grupo',
+      cancel: 'Cancelar',
+      saving: 'Salvando...',
+      creating: 'Criando...',
+      saveChanges: 'Salvar Alterações',
+    },
+    form: {
+      groupName: 'Nome do Grupo',
+      groupNamePlaceholder: 'Insira o nome do grupo',
+      systemGroupWarning: 'Os nomes dos grupos do sistema não podem ser alterados',
+      description: 'Descrição',
+      descriptionPlaceholder: 'Insira a descrição (opcional)',
+      permissions: 'Permissões ({{count}} selecionadas)',
+    },
+    deleteModal: {
+      title: 'Excluir Grupo',
+      message: 'Tem certeza de que deseja excluir este grupo? Os usuários deste grupo perderão essas permissões.',
+      confirm: 'Excluir Grupo',
+    },
+  },
+
+  // Users management
+  users: {
+    title: 'Gerenciamento de Usuários',
+    subtitle: 'Gerenciar usuários e seu acesso à sua instância do Bambuddy',
+    backToSettings: 'Voltar para Configurações',
+    createUser: 'Criar Usuário',
+    noPermission: 'Você não tem permissão para acessar esta página.',
+    admin: 'Admin',
+    noGroups: 'Sem grupos',
+    active: 'Ativo',
+    inactive: 'Inativo',
+    edit: 'Editar',
+    delete: 'Excluir',
+    system: 'Sistema',
+    noGroupsAvailable: 'Nenhum grupo disponível',
+    table: {
+      username: 'Nome de Usuário',
+      groups: 'Grupos',
+      status: 'Status',
+      actions: 'Ações',
+    },
+    toast: {
+      created: 'Usuário criado com sucesso',
+      updated: 'Usuário atualizado com sucesso',
+      deleted: 'Usuário excluído com sucesso',
+      fillRequired: 'Por favor, preencha todos os campos obrigatórios',
+      passwordsDoNotMatch: 'As senhas não coincidem',
+      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+    },
+    modal: {
+      createUser: 'Criar Usuário',
+      editUser: 'Editar Usuário',
+      cancel: 'Cancelar',
+      creating: 'Criando...',
+      saving: 'Salvando...',
+      saveChanges: 'Salvar Alterações',
+      advancedAuthSubtitle: 'com Autenticação Avançada',
+    },
+    form: {
+      username: 'Nome de Usuário',
+      usernamePlaceholder: 'Insira o nome de usuário',
+      email: 'Email',
+      emailPlaceholder: 'user@example.com',
+      password: 'Senha',
+      passwordPlaceholder: 'Insira a senha',
+      confirmPassword: 'Confirmar Senha',
+      confirmPasswordPlaceholder: 'Confirme a senha',
+      newPasswordPlaceholder: 'Insira a nova senha',
+      confirmNewPasswordPlaceholder: 'Confirme a nova senha',
+      leaveBlankToKeep: 'deixe em branco para manter a atual',
+      groups: 'Grupos',
+      optional: 'opcional',
+      autoGeneratedPassword: 'Uma senha segura será gerada automaticamente e enviada por e-mail ao usuário.',
+      passwordManagedByAdvancedAuth: 'A senha é gerenciada pela Autenticação Avançada. Use "Redefinir Senha" para enviar uma nova senha ao usuário por e-mail.',
+      resetPassword: 'Redefinir Senha',
+      resettingPassword: 'Redefinindo Senha...',
+    },
+    deleteModal: {
+      title: 'Excluir Usuário',
+      message: 'Tem certeza de que deseja excluir este usuário? Esta ação não pode ser desfeita.',
+      confirm: 'Excluir Usuário',
+    },
+  },
+
+  // Stream overlay
+  streamOverlay: {
+    title: 'Stream Overlay',
+    invalidPrinterId: 'ID da impressora inválido',
+    cameraStream: 'Transmissão da câmera',
+    progress: 'Progresso da impressão',
+    eta: 'ETA',
+    printerIdle: 'Impressora ociosa',
+    printerOffline: 'Impressora offline',
+    status: {
+      printing: 'Imprimindo',
+      paused: 'Pausado',
+      finished: 'Concluído',
+      failed: 'Falhou',
+      idle: 'Ocioso',
+      unknown: 'Desconhecido',
+    },
+  },
+
+  // Profiles
+  profiles: {
+    title: 'Perfis',
+    subtitle: 'Gerencie seus presets de fatiador e calibrações de avanço de pressão',
+    tabs: {
+      cloud: 'Perfis na Nuvem',
+      local: 'Perfis Locais',
+      kprofiles: 'K-Perfis',
+    },
+    localProfiles: {
+      title: 'Perfis Locais',
+      subtitle: 'Importe e gerencie presets de fatiador do OrcaSlicer',
+      import: 'Importar Perfis',
+      importDesc: 'Solte arquivos .bbscfg, .bbsflmt, .orca_filament, .zip ou .json aqui',
+      importing: 'Importando...',
+      search: 'Pesquisar presets locais...',
+      noPresets: 'Nenhum preset local ainda',
+      badge: 'Local',
+      edit: 'Editar',
+      delete: 'Excluir',
+      cancel: 'Cancelar',
+      deleteConfirmTitle: 'Excluir Preset',
+      deleteConfirm: 'Tem certeza de que deseja excluir este preset? Esta ação não pode ser desfeita.',
+      source: 'Fonte',
+      inheritsFrom: 'Herdado de',
+      filamentType: 'Tipo',
+      vendor: 'Fornecedor',
+      compatiblePrinters: 'Impressoras Compatíveis',
+      nozzleTemp: 'Temperatura do Bico',
+      cost: 'Custo',
+      density: 'Densidade',
+      pressureAdvance: 'Avanço de Pressão',
+      filament: 'Filamento',
+      process: 'Processo',
+      printer: 'Impressora',
+      toast: {
+        importSuccess: '{{count}} preset(s) importada(s)',
+        importSkipped: '{{count}} preset(s) ignorada(s) (duplicadas)',
+        importError: '{{count}} erro(s) durante a importação',
+        deleted: 'Preset excluído',
+        updated: 'Preset atualizado',
+      },
+    },
+    connectedAs: 'Conectado como',
+    logout: 'Sair',
+    noLogoutPermission: 'Você não tem permissão para sair',
+    failedToLoad: 'Falha ao carregar perfis',
+    retry: 'Tentar novamente',
+    time: {
+      justNow: 'Agora mesmo',
+      minsAgo: 'há {{count}} minutos',
+      hoursAgo: 'há {{count}} horas',
+      daysAgo: 'há {{count}} dias',
+    },
+    toast: {
+      loggedOut: 'Desconectado',
+    },
+    login: {
+      title: 'Conectar ao Bambu Cloud',
+      subtitle: 'Sincronize seus presets de fatiador entre dispositivos',
+      email: 'Email',
+      password: 'Senha',
+      region: 'Região',
+      regionGlobal: 'Global',
+      regionChina: 'China',
+      verificationCode: 'Código de Verificação',
+      totpCode: 'Código do Autenticador',
+      checkEmail: 'Verifique seu email ({{email}}) para um código de 6 dígitos',
+      enterTotpHint: 'Digite o código de 6 dígitos do seu aplicativo autenticador',
+      accessToken: 'Token de Acesso',
+      accessTokenHint: 'Cole seu token de acesso Bambu Lab (do Bambu Studio)',
+      back: 'Voltar',
+      loginButton: 'Entrar',
+      verifyButton: 'Verificar',
+      setTokenButton: 'Definir Token',
+      useToken: 'Usar token de acesso em vez disso',
+      useEmail: 'Entrar com email em vez disso',
+      toast: {
+        loggedIn: 'Conectado com sucesso',
+        codeSent: 'Código de verificação enviado para seu email',
+        enterTotp: 'Digite o código do seu aplicativo autenticador',
+        tokenSet: 'Token definido com sucesso',
+      },
+    },
+    presets: {
+      myPreset: 'Meu preset (editável)',
+      duplicate: 'Duplicar',
+      editable: 'Editável',
+      failedToLoadDetails: 'Falha ao carregar detalhes do preset',
+      deleteConfirm: 'Excluir este preset?',
+      deleteWarning: 'Isso excluirá permanentemente "{{name}}" do Bambu Cloud. Esta ação não pode ser desfeita.',
+      noDuplicatePermission: 'Você não tem permissão para duplicar presets',
+      noEditPermission: 'Você não tem permissão para editar presets',
+      noDeletePermission: 'Você não tem permissão para excluir presets',
+      types: {
+        filament: 'Preset de filamento',
+        printer: 'Preset de impressora',
+        process: 'Preset de processo',
+      },
+      toast: {
+        deleted: 'Preset excluído',
+        created: 'Preset criado',
+        updated: 'Preset atualizado',
+        duplicated: 'Preset duplicado',
+        fieldAdded: 'Campo "{{key}}" adicionado',
+        exported: 'Preset exportado',
+      },
+      baseLabel: 'Base: {{name}}',
+      currentLabel: 'Atual: {{name}}',
+      newPreset: 'Novo Preset',
+      editPreset: 'Editar Preset',
+      duplicatePreset: 'Duplicar Preset',
+      createNewPreset: 'Criar Novo Preset',
+      customizeSettings: 'Personalizar configurações para seu novo preset',
+      compareWithBase: 'Comparar com o preset base',
+      compare: 'Comparar',
+      // CreatePresetModal - Basic Info
+      basePreset: 'Preset Base',
+      selectBasePreset: 'Selecionar preset base...',
+      presetName: 'Nome do Preset',
+      myCustomPreset: 'Meu preset personalizado',
+      inheritsFrom: 'Herdado de',
+      dropJsonToImport: 'Solte o arquivo JSON para importar',
+      // CreatePresetModal - Tabs
+      tabs: {
+        common: 'Comum',
+        allFields: 'Todos os Campos',
+      },
+      // CreatePresetModal - All Fields Tab
+      availableFields: 'Campos Disponíveis',
+      searchFieldsPlaceholder: 'Pesquisar campos...',
+      noMatchingFields: 'Nenhum campo correspondente',
+      allFieldsAdded: 'Todos os campos adicionados',
+      addCustomField: 'Adicionar campo personalizado',
+      yourOverrides: 'Suas Substituições',
+      noOverridesYet: 'Nenhuma substituição ainda',
+      clickFieldsToAdd: 'Clique nos campos à esquerda para adicioná-los',
+      saveAsTemplate: 'Salvar como modelo',
+      jsonTip: 'Dica: Arraste e solte um arquivo .json em qualquer lugar deste modal para importar configurações',
+    },
+    cloudView: {
+      searchPlaceholder: 'Pesquisar presets...',
+      templates: 'Modelos',
+      refresh: 'Atualizar',
+      newPreset: 'Novo Preset',
+      clearFilters: 'Limpar filtros',
+      // Compare mode
+      compareMode: 'Modo de Comparação',
+      selectAnotherPreset: 'Selecionar outro preset {{type}}',
+      clickTwoPresets: 'Clique em dois presets do mesmo tipo para comparar',
+      selectFirst: '1. Selecionar primeiro',
+      selectSecond: '2. Selecionar segundo',
+      compareNow: 'Comparar Agora',
+      // Status row
+      lastSynced: 'Última sincronização:',
+      showingCount: 'Mostrando {{showing}} de {{total}} presets',
+      noPresetsFound: 'Nenhum preset encontrado',
+      // Column headers
+      columns: {
+        filament: 'Filamento',
+        process: 'Processo',
+        printer: 'Impressora',
+      },
+      noFilamentPresets: 'Nenhum preset de filamento',
+      noProcessPresets: 'Nenhum preset de processo',
+      noPrinterPresets: 'Nenhum preset de impressora',
+      // Filters
+      filters: {
+        type: 'Tipo',
+        owner: 'Proprietário',
+        printer: 'Impressora',
+        nozzle: 'Bico',
+        filament: 'Filamento',
+        layer: 'Camada',
+        all: 'Todos',
+        myPresets: 'Meus Presets',
+        builtIn: 'Integrado',
+        process: 'Processo',
+      },
+      // Permissions
+      noTemplatesPermission: 'Você não tem permissão para gerenciar modelos',
+      noRefreshPermission: 'Você não tem permissão para atualizar perfis',
+      noCreatePermission: 'Você não tem permissão para criar presets',
+    },
+    templates: {
+      title: 'Modelos Rápidos',
+      noTemplates: 'Nenhum modelo ainda',
+      createFirst: 'Crie modelos a partir do editor de presets',
+      typeFilter: 'Tipo:',
+      deleteTitle: 'Excluir Modelo',
+      deleteWarning: 'Esta ação não pode ser desfeita',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+      namePlaceholder: 'Nome do modelo',
+      descriptionPlaceholder: 'Descrição',
+      settingsJson: 'Configurações (JSON)',
+      fieldsCount: '{{count}} campos',
+      shownInModals: 'Exibido em modais',
+      hiddenInModals: 'Oculto em modais',
+      apply: 'Aplicar',
+      toast: {
+        deleted: 'Modelo excluído',
+        updated: 'Modelo atualizado',
+        created: 'Modelo criado',
+        applied: 'Modelo aplicado',
+      },
+    },
+  },
+
+  // Support/Debug
+  support: {
+    debugLoggingActive: 'Registro de depuração ativo',
+    manageLogs: 'Gerenciar',
+    collectItem7: 'Conectividade da impressora e versões de firmware',
+    collectItem8: 'Status de integração (Spoolman, MQTT, HA)',
+    collectItem9: 'Interfaces de rede (somente sub-redes)',
+    collectItem10: 'Versões de pacotes Python',
+    collectItem11: 'Verificações de integridade do banco de dados',
+    collectItem12: 'Detalhes do ambiente Docker',
+  },
+
+  // File manager
+  fileManager: {
+    title: 'Gerenciador de Arquivos',
+    subtitle: 'Organize e gerencie seus arquivos de impressão',
+    uploadFiles: 'Enviar Arquivos',
+    newFolder: 'Nova Pasta',
+    folderName: 'Nome da Pasta',
+    folderNamePlaceholder: 'ex.: Peças Funcionais',
+    renameFile: 'Renomear Arquivo',
+    renameFolder: 'Renomear Pasta',
+    moveFiles: 'Mover {{count}} Arquivo(s)',
+    rootNoFolder: 'Raiz (Sem Pasta)',
+    current: 'Atual',
+    linkFolder: 'Vincular Pasta',
+    linkFolderDescription: 'Vincular "{{name}}" a um projeto ou arquivo para acesso rápido.',
+    project: 'Projeto',
+    archive: 'Arquivo',
+    noProjectsFound: 'Nenhum projeto encontrado',
+    noArchivesFound: 'Nenhum arquivo encontrado',
+    unlink: 'Desvincular',
+    link: 'Vincular',
+    dragDropFiles: 'Arraste e solte os arquivos aqui',
+    dropFilesHere: 'Solte os arquivos aqui',
+    orClickToBrowse: 'ou clique para procurar',
+    allFileTypesSupported: 'Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.',
+    zipFilesDetected: 'Arquivos ZIP detectados',
+    zipExtractOptions: 'Arquivos ZIP serão extraídos. Escolha como lidar com a estrutura de pastas:',
+    preserveZipStructure: 'Preservar estrutura de pastas do ZIP',
+    createFolderFromZip: 'Criar pasta a partir do nome do arquivo ZIP',
+    stlThumbnailGeneration: 'Geração de miniaturas STL',
+    zipMayContainStl: 'Arquivos ZIP podem conter arquivos STL. Miniaturas podem ser geradas durante a extração.',
+    thumbnailsCanBeGenerated: 'Miniaturas podem ser geradas para arquivos STL. Modelos grandes podem levar mais tempo para processar.',
+    generateThumbnailsForStl: 'Gerar miniaturas para arquivos STL',
+    threemfDetected: 'Arquivos 3MF detectados',
+    threemfExtractionInfo: 'Modelo da impressora, material, cor e configurações de impressão serão extraídos automaticamente dos arquivos 3MF.',
+    willBeExtracted: 'Será extraído',
+    filesExtracted: '{{count}} arquivos extraídos',
+    uploadComplete: 'Upload concluído: {{succeeded}} bem-sucedidos',
+    uploadFailed: '{{count}} falhou',
+    uploading: 'Enviando...',
+    changeLink: 'Alterar link...',
+    linkTo: 'Vincular a...',
+    linkToProjectOrArchive: 'Vincular a projeto ou arquivo',
+    addToQueue: 'Adicionar à fila',
+    schedulePrint: 'Agendar impressão',
+    generateThumbnail: 'Gerar miniatura',
+    generateThumbnails: 'Gerar miniaturas',
+    generateThumbnailsForMissing: 'Gerar miniaturas para arquivos STL que não possuem',
+    gridView: 'Visualização em grade',
+    listView: 'Visualização em lista',
+    lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
+    lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',
+    files: 'Arquivos',
+    folders: 'Pastas',
+    size: 'Tamanho',
+    free: 'Livre',
+    allFiles: 'Todos os arquivos',
+    wrap: 'Quebrar texto',
+    enableTextWrapping: 'Ativar quebra de texto',
+    disableTextWrapping: 'Desativar quebra de texto',
+    dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
+    searchFiles: 'Pesquisar arquivos...',
+    allTypes: 'Todos os tipos',
+    prints: 'Impressões',
+    ascending: 'Crescente',
+    descending: 'Decrescente',
+    resultsCount: '{{showing}} de {{total}} arquivos',
+    selectAll: 'Selecionar tudo',
+    deselectAll: 'Desmarcar tudo',
+    selected: '{{count}} selecionado(s)',
+    adding: 'Adicionando...',
+    loadingFiles: 'Carregando arquivos...',
+    folderIsEmpty: 'A pasta está vazia',
+    noFilesYet: 'Nenhum arquivo ainda',
+    folderEmptyDescription: 'Envie arquivos ou mova arquivos para esta pasta para começar.',
+    noFilesDescription: 'Envie arquivos para começar a organizar seus arquivos relacionados à impressão.',
+    noMatchingFiles: 'Nenhum arquivo correspondente',
+    noMatchingFilesDescription: 'Nenhum arquivo corresponde aos seus critérios de pesquisa ou filtro.',
+    clearFilters: 'Limpar filtros',
+    printedCount: 'Impresso {{count}}x',
+    uploadedBy: 'Enviado por',
+    deleteFolder: 'Excluir pasta',
+    deleteFile: 'Excluir arquivo',
+    deleteFilesCount: 'Excluir {{count}} arquivos',
+    deleteFolderConfirm: 'Tem certeza de que deseja excluir esta pasta? Todos os arquivos dentro também serão excluídos.',
+    deleteFileConfirm: 'Tem certeza de que deseja excluir este arquivo?',
+    deleteFilesConfirm: 'Tem certeza de que deseja excluir {{count}} arquivos selecionados? Esta ação não pode ser desfeita.',
+    deleting: 'Excluindo...',
+    noPermissionRenameFolder: 'Você não tem permissão para renomear pastas',
+    noPermissionLinkFolder: 'Você não tem permissão para vincular pastas',
+    noPermissionDeleteFolder: 'Você não tem permissão para excluir pastas',
+    noPermissionPrint: 'Você não tem permissão para imprimir',
+    noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',
+    noPermissionDownload: 'Você não tem permissão para baixar arquivos',
+    noPermissionRenameFile: 'Você não tem permissão para renomear este arquivo',
+    noPermissionGenerateThumbnail: 'Você não tem permissão para gerar miniaturas',
+    noPermissionDeleteFile: 'Você não tem permissão para excluir este arquivo',
+    noPermissionCreateFolder: 'Você não tem permissão para criar pastas',
+    noPermissionUpload: 'Você não tem permissão para enviar arquivos',
+    noPermissionMoveFiles: 'Você não tem permissão para mover arquivos',
+    noPermissionDeleteFiles: 'Você não tem permissão para excluir arquivos',
+    toast: {
+      folderCreated: 'Pasta criada',
+      folderDeleted: 'Pasta excluída',
+      fileDeleted: 'Arquivo excluído',
+      filesDeleted: 'Excluídos {{count}} arquivos',
+      filesMoved: 'Arquivos movidos',
+      folderLinked: 'Pasta vinculada',
+      folderUnlinked: 'Pasta desvinculada',
+      addedToQueue: 'Adicionado {{count}} arquivo(s) à fila',
+      addedToQueuePartial: 'Adicionado {{added}} arquivo(s), {{failed}} falharam',
+      failedToAddToQueue: 'Falha ao adicionar arquivos: {{error}}',
+      fileRenamed: 'Arquivo renomeado',
+      folderRenamed: 'Pasta renomeada',
+      thumbnailsGenerated: 'Geradas {{count}} miniatura(s)',
+      thumbnailsGeneratedPartial: 'Geradas {{succeeded}} miniatura(s), {{failed}} falharam',
+      noStlMissingThumbnails: 'Nenhum arquivo STL sem miniatura',
+      failedToGenerateThumbnails: 'Falha ao gerar miniaturas: {{error}}',
+      thumbnailGenerated: 'Miniatura gerada',
+      failedToGenerateThumbnail: 'Falha ao gerar miniatura: {{error}}',
+    },
+  },
+
+  // Projects
+  projects: {
+    title: 'Projetos',
+    subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
+    newProject: 'Novo Projeto',
+    editProject: 'Editar Projeto',
+    deleteProject: 'Excluir Projeto',
+    projectName: 'Nome do Projeto',
+    description: 'Descrição',
+    noProjects: 'Nenhum projeto ainda',
+    noProjectsFiltered: 'Nenhum projeto {{status}}',
+    noProjectsFilteredHelp: "Você não tem nenhum projeto {{status}}. Os projetos aparecerão aqui quando seu status mudar.",
+    createFirst: 'Crie seu primeiro projeto para começar a organizar impressões relacionadas, acompanhar o progresso e gerenciar suas construções.',
+    createFirstButton: 'Crie Seu Primeiro Projeto',
+    create: 'Criar',
+    files: 'Arquivos',
+    prints: 'Impressões',
+    plates: 'Placas',
+    parts: 'Peças',
+    lastModified: 'Última Modificação',
+    deleteConfirm: 'Tem certeza de que deseja excluir este projeto? Arquivos e itens da fila serão desvinculados, mas não excluídos.',
+    addFiles: 'Adicionar Arquivos',
+    removeFile: 'Remover Arquivo',
+    viewDetails: 'Ver Detalhes',
+    // Modal fields
+    namePlaceholder: 'ex., Voron 2.4 Build',
+    descriptionPlaceholder: 'Descrição opcional...',
+    color: 'Cor',
+    targetPlates: 'Placas Alvo',
+    targetPlatesPlaceholder: 'ex., 25',
+    targetPlatesHelp: 'Número de trabalhos de impressão',
+    targetParts: 'Peças Alvo',
+    targetPartsPlaceholder: 'ex., 150',
+    targetPartsHelp: 'Total de objetos necessários',
+    tagsLabel: 'Tags (separadas por vírgula)',
+    tagsPlaceholder: 'ex., voron, funcional, presente',
+    dueDate: 'Data de Vencimento',
+    priority: 'Prioridade',
+    priorityLow: 'Baixa',
+    priorityNormal: 'Normal',
+    priorityHigh: 'Alta',
+    priorityUrgent: 'Urgente',
+    // Status
+    statusActive: 'Ativo',
+    statusCompleted: 'Concluído',
+    statusArchived: 'Arquivado',
+    done: 'Concluído',
+    completed: 'Concluído',
+    failed: 'Falhou',
+    inQueue: 'Na fila',
+    noPrintsYet: 'Nenhuma impressão ainda',
+    // Footer stats
+    printJobs: 'Trabalhos de impressão (placas)',
+    partsPrinted: 'Peças impressas',
+    failedParts: 'Peças falhadas',
+    // Actions
+    import: 'Importar',
+    export: 'Exportar',
+    importProject: 'Importar projeto',
+    exportAll: 'Exportar todos os projetos',
+    loading: 'Carregando projetos...',
+    // Permissions
+    noEditPermission: 'Você não tem permissão para editar projetos',
+    noDeletePermission: 'Você não tem permissão para excluir projetos',
+    noCreatePermission: 'Você não tem permissão para criar projetos',
+    noImportPermission: 'Você não tem permissão para importar projetos',
+    noExportPermission: 'Você não tem permissão para exportar projetos',
+    // Toast
+    toast: {
+      created: 'Projeto criado',
+      updated: 'Projeto atualizado',
+      deleted: 'Projeto excluído',
+      imported: 'Projeto importado',
+      multipleImported: '{{count}} projetos importados',
+      importFailed: 'Falha na importação',
+      exported: 'Projetos exportados (apenas metadados)',
+    },
+  },
+
+  // Project detail page
+  projectDetail: {
+    notFound: 'Projeto não encontrado',
+    backToProjects: 'Voltar para Projetos',
+    export: 'Exportar',
+    exportProject: 'Exportar projeto',
+    noExportPermission: 'Você não tem permissão para exportar projetos',
+    noEditPermission: 'Você não tem permissão para editar projetos',
+    partOf: 'Parte de:',
+    priorityLabel: 'Prioridade:',
+    noPrints: 'Nenhuma impressão neste projeto ainda',
+    status: {
+      active: 'Ativo',
+      completed: 'Concluído',
+      archived: 'Arquivado',
+    },
+    priority: {
+      low: 'Baixa',
+      normal: 'Normal',
+      high: 'Alta',
+      urgent: 'Urgente',
+    },
+    dueDate: {
+      overdue: 'Atrasado',
+      today: 'Vence hoje',
+      daysLeft: '{{count}} dias restantes',
+    },
+    progress: {
+      platesProgress: 'Progresso das Placas',
+      partsProgress: 'Progresso das Peças',
+      printJobs: 'Trabalhos de Impressão',
+      parts: 'Peças',
+      percentComplete: '{{percent}}% concluído',
+      remaining: '{{count}} restantes',
+    },
+    stats: {
+      printJobs: 'Trabalhos de Impressão',
+      total: 'total',
+      failed: '{{count}} falhou',
+      partsPrinted: '{{count}} peças impressas',
+      printTime: 'Tempo de Impressão',
+      filamentUsed: 'Filamento Usado',
+    },
+    cost: {
+      title: 'Rastreamento de Custos',
+      filamentCost: 'Custo do Filamento',
+      energy: 'Energia',
+      budget: 'Orçamento',
+      remaining: 'Restante',
+    },
+    subProjects: {
+      title: 'Sub-projetos ({{count}})',
+    },
+    notes: {
+      title: 'Notas',
+      noEditPermission: 'Você não tem permissão para editar notas',
+      placeholder: 'Adicione notas sobre este projeto...',
+      empty: 'Nenhuma nota ainda. Clique em Editar para adicionar notas.',
+    },
+    files: {
+      title: 'Arquivos',
+      linkFolders: 'Vincular pastas do Gerenciador de Arquivos',
+      forQuickAccess: 'a este projeto para acesso rápido.',
+      fileCount: '{{count}} arquivo(s)',
+      empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
+    },
+    bom: {
+      title: 'Lista de Materiais',
+      acquired: '{{completed}}/{{total}} adquiridos',
+      showAll: 'Mostrar todos',
+      hideDone: 'Ocultar concluídos',
+      addPart: 'Adicionar Peça',
+      noAddPermission: 'Você não tem permissão para adicionar peças',
+      partNamePlaceholder: 'Nome da peça (ex.: parafusos M3x8)',
+      partName: 'Nome da peça',
+      qty: 'Quantidade',
+      price: 'Preço ({{currency}})',
+      sourcingUrlPlaceholder: 'URL de fornecimento (opcional)',
+      remarksPlaceholder: 'Observações (opcional)',
+      deletePart: 'Excluir Peça',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+      noUpdatePermission: 'Você não tem permissão para atualizar peças',
+      noEditPermission: 'Você não tem permissão para editar peças',
+      noDeletePermission: 'Você não tem permissão para excluir peças',
+      totalCost: 'Custo total:',
+      empty: 'Nenhuma peça na lista de materiais. Adicione hardware, eletrônicos ou outros componentes para rastrear o que precisa ser adquirido.',
+    },
+    timeline: {
+      title: 'Linha do Tempo de Atividades',
+      empty: 'Nenhuma atividade ainda.',
+    },
+    template: {
+      saveAsTemplate: 'Salvar como Modelo',
+      noCreatePermission: 'Você não tem permissão para criar modelos',
+    },
+    queue: {
+      title: 'Fila',
+      viewAll: 'Ver todos',
+      printing: '{{count}} imprimindo',
+      queued: '{{count}} na fila',
+    },
+    prints: {
+      title: 'Impressões ({{count}})',
+    },
+    toast: {
+      projectUpdated: 'Projeto atualizado',
+      partAdded: 'Peça adicionada',
+      partRemoved: 'Peça removida',
+      exportFailed: 'Falha na exportação',
+      projectExported: 'Projeto exportado',
+      templateCreated: 'Modelo criado',
+    },
+  },
+
+  // System info
+  system: {
+    title: 'Informações do Sistema',
+    version: 'Versão',
+    uptime: 'Tempo de Atividade',
+    cpuUsage: 'Uso da CPU',
+    memoryUsage: 'Uso da Memória',
+    diskUsage: 'Uso do Disco',
+    networkInfo: 'Informações de Rede',
+    logs: 'Logs',
+    debugMode: 'Modo de Depuração',
+    enableDebug: 'Ativar Registro de Depuração',
+    disableDebug: 'Desativar Registro de Depuração',
+    downloadLogs: 'Baixar Logs',
+    clearLogs: 'Limpar Logs',
+    dockerInfo: 'Informações do Docker',
+    containerName: 'Nome do Contêiner',
+    imageName: 'Nome da Imagem',
+    platform: 'Plataforma',
+    architecture: 'Arquitetura',
+  },
+
+  // Library (K Profiles)
+  library: {
+    title: 'Biblioteca de Filamentos',
+    addFilament: 'Adicionar Filamento',
+    editFilament: 'Editar Filamento',
+    deleteFilament: 'Excluir Filamento',
+    vendor: 'Fornecedor',
+    material: 'Material',
+    color: 'Cor',
+    kFactor: 'Fator K',
+    temperature: 'Temperatura',
+    noFilaments: 'Nenhum filamento na biblioteca',
+    deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',
+    importFromPrinter: 'Importar da Impressora',
+    exportToFile: 'Exportar para Arquivo',
+  },
+
+  // Spoolman
+  spoolman: {
+    title: 'Integração com Spoolman',
+    enabled: 'Spoolman Ativado',
+    url: 'URL do Spoolman',
+    connected: 'Conectado',
+    disconnected: 'Não Conectado',
+    testConnection: 'Testar Conexão',
+    sync: 'Sincronizar',
+    syncing: 'Sincronizando...',
+    lastSync: 'Última Sincronização',
+    linkToSpoolman: 'Vincular ao Spoolman',
+    openInSpoolman: 'Abrir no Spoolman',
+    unlinkSpool: 'Desvincular Carretel',
+    selectSpool: 'Selecionar Carretel',
+    noUnlinkedSpools: 'Nenhum carretel desvinculado disponível',
+    linkSuccess: 'Carretel vinculado ao Spoolman com sucesso',
+    linkFailed: 'Falha ao vincular carretel',
+    spoolId: 'Carretel ID (Spool ID)',
+    fillSourceLabel: '(Spoolman)',
+    weight: 'Peso',
+    remaining: 'Restante',
+    disableWeightSync: 'Desativar Sincronização de Peso Estimado do AMS',
+    disableWeightSyncDesc: "Não atualize a capacidade restante a partir das estimativas do AMS. Use isso se preferir o rastreamento de uso do Spoolman em vez das estimativas baseadas em porcentagem do AMS. Novos carretéis ainda usarão a estimativa do AMS como seu peso inicial.",
+    reportPartialUsage: 'Relatar Uso Parcial para Impressões Falhadas',
+    reportPartialUsageDesc: 'Quando uma impressão falha ou é cancelada, relate o filamento estimado usado até aquele ponto com base no progresso das camadas.',
+  },
+
+  // Inventory
+  inventory: {
+    title: 'Inventário de Carretéis',
+    addSpool: 'Adicionar Carretel',
+    editSpool: 'Editar Carretel',
+    material: 'Material',
+    selectMaterial: 'Selecionar material...',
+    subtype: 'Subtipo',
+    brand: 'Marca',
+    searchBrand: 'Pesquisar marca...',
+    useCustomBrand: 'Usar "{{brand}}"',
+    colorName: 'Nome da Cor',
+    colorNamePlaceholder: 'Jade White, Fire Red...',
+    color: 'Cor',
+    hexColor: 'Cor Hexadecimal',
+    pickColor: 'Escolher cor personalizada',
+    labelWeight: 'Peso da Etiqueta',
+    coreWeight: 'Peso do Carretel Vazio',
+    searchSpoolWeight: 'Pesquisar peso do carretel...',
+    weightUsed: 'Usado',
+    currentWeight: 'Peso Restante',
+    measuredWeight: 'Peso Medido',
+    measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.',
+    slicerFilament: 'Filamento do Fatiador',
+    slicerFilamentName: 'Nome do Predefinido do Fatiador',
+    slicerPreset: 'Predefinido do Fatiador',
+    searchPresets: 'Pesquisar predefinições de filamento...',
+    selectedPreset: 'Selecionado',
+    noPresetsFound: 'Nenhuma predefinição encontrada',
+    tempOverrides: 'Substituições de Temperatura',
+    note: 'Nota',
+    notePlaceholder: 'Quaisquer notas adicionais sobre este spool...',
+    archive: 'Arquivar',
+    restore: 'Restaurar',
+    noSpools: 'Nenhum carretel ainda. Adicione seu primeiro carretel para começar.',
+    noManualSpools: 'Nenhum carretel adicionado manualmente disponível. Adicione um carretel ao seu inventário primeiro.',
+    kProfiles: 'K-Perfis',
+    addKProfile: 'Adicionar K-Perfil',
+    assignSpool: 'Atribuir Carretel',
+    unassignSpool: 'Desatribuir',
+    assignSuccess: 'Carretel atribuído e slot AMS configurado',
+    assignFailed: 'Falha ao atribuir carretel',
+    selectSpool: 'Selecione um carretel para atribuir a este slot',
+    assigned: 'Atribuído',
+    assigning: 'Atribuindo...',
+    searchSpools: 'Pesquisar carretéis...',
+    allMaterials: 'Todos os Materiais',
+    filterByBrand: 'Filtrar por marca...',
+    showArchived: 'Mostrar arquivados',
+    spoolCreated: 'Carretel criado',
+    spoolUpdated: 'Carretel atualizado',
+    spoolDeleted: 'Carretel excluído',
+    spoolArchived: 'Carretel arquivado',
+    spoolRestored: 'Carretel restaurado',
+    deleteConfirm: 'Tem certeza de que deseja excluir este carretel? Esta ação não pode ser desfeita.',
+    archiveConfirm: 'Tem certeza de que deseja arquivar este carretel?',
+    advancedSettings: 'Configurações Avançadas',
+    // Tabs
+    filamentInfoTab: 'Informações do Filamento',
+    paProfileTab: 'Perfil PA',
+    filamentInfo: 'Filamento',
+    additional: 'Adicional',
+    // Cloud
+    loadingPresets: 'Carregando predefinições da nuvem...',
+    cloudConnected: 'Nuvem conectada',
+    cloudNotConnected: 'Nuvem não conectada (usando padrões)',
+    // Colors
+    recentColors: 'Recentes',
+    searchColors: 'Pesquisar cores...',
+    searchResults: 'Resultados da pesquisa',
+    allColors: 'Todas as cores',
+    commonColors: 'Cores comuns',
+    showLess: 'Mostrar menos',
+    showAll: 'Mostrar tudo',
+    noColorsFound: 'Nenhuma cor corresponde à sua pesquisa',
+    noResults: 'Nenhum resultado encontrado',
+    // PA Profiles
+    selectMaterialFirst: 'Por favor, selecione um material primeiro na aba Informações do Filamento.',
+    noPrintersConfigured: 'Nenhuma impressora configurada. Adicione impressoras para usar perfis PA.',
+    matchingFilter: 'Correspondente',
+    anyBrand: 'Qualquer marca',
+    anyVariant: 'Qualquer variante',
+    autoSelect: 'Seleção automática',
+    matches: 'correspondências',
+    match: 'correspondência',
+    noMatches: 'Nenhuma correspondência',
+    connected: 'Conectado',
+    offline: 'Offline',
+    printerOffline: 'A impressora está offline. Conecte-se para visualizar os perfis de calibração.',
+    noKProfilesMatch: 'Nenhum K-perfil corresponde ao filamento selecionado.',
+    leftNozzle: 'Bico Esquerdo',
+    rightNozzle: 'Bico Direito',
+    profilesSelected: 'perfil(is) de calibração selecionado(s)',
+    // Stats & enhanced table
+    totalInventory: 'Inventário Total',
+    totalConsumed: 'Total Consumido',
+    byMaterial: 'Por Material',
+    inPrinter: 'Na Impressora',
+    lowStock: 'Estoque Baixo',
+    sinceTracking: 'Desde o início do rastreamento',
+    loadedInAms: 'Carregado no AMS/Ext',
+    remaining: 'Restante',
+    lowStockThreshold: '<20% restante',
+    search: 'Pesquisar carretéis...',
+    showing: 'Mostrando',
+    to: 'até',
+    of: 'de',
+    show: 'Mostrar',
+    spools: 'carretéis',
+    spool: 'carretel',
+    page: 'Página',
+    noSpoolsMatch: 'Nenhum resultado encontrado',
+    noSpoolsMatchDesc: 'Tente ajustar sua pesquisa ou filtros para encontrar o que você está procurando.',
+    active: 'Ativo',
+    archived: 'Arquivado',
+    all: 'Todos',
+    used: 'Usado',
+    new: 'Novo',
+    clearFilters: 'Limpar filtros',
+    table: 'Tabela',
+    cards: 'Cartões',
+    net: 'Líquido',
+    // Column config
+    columns: 'Colunas',
+    configureColumns: 'Configurar Colunas',
+    configureColumnsDesc: 'Arraste para reordenar as colunas ou use as setas. Alterne a visibilidade com o ícone de olho.',
+    visible: 'Visível',
+    reset: 'Redefinir',
+    cancel: 'Cancelar',
+    applyChanges: 'Aplicar Alterações',
+    moveUp: 'Mover para cima',
+    moveDown: 'Mover para baixo',
+    hideColumn: 'Ocultar coluna',
+    showColumn: 'Mostrar coluna',
+    // Tag linking
+    linkToSpool: 'Vincular ao Carretel',
+    tagLinked: 'Tag vinculada ao carretel',
+    tagLinkFailed: 'Falha ao vincular tag',
+    tagAlreadyLinked: 'Tag já vinculada a outro carretel',
+    unknownTag: 'Tag RFID desconhecida detectada',
+    // Usage history
+    usageHistory: 'Histórico de Uso',
+    noUsageHistory: 'Nenhum uso registrado ainda',
+    printName: 'Nome da Impressão',
+    weightConsumed: 'Peso Consumido',
+    clearHistory: 'Limpar',
+    historyCleared: 'Histórico de uso limpo',
+    fillSourceLabel: '(Inv)',
+  },
+
+  // Timelapse
+  timelapse: {
+    title: 'Timelapse',
+    create: 'Criar Timelapse',
+    download: 'Baixar',
+    delete: 'Excluir',
+    preview: 'Visualizar',
+    frameRate: 'Taxa de Quadros',
+    quality: 'Qualidade',
+    processing: 'Processando...',
+    noTimelapses: 'Nenhum timelapse disponível',
+  },
+
+  // AMS
+  ams: {
+    title: 'AMS',
+    slot: 'Slot',
+    empty: 'Vazio',
+    emptySlot: 'Slot vazio',
+    unknown: 'Desconhecido',
+    humidity: 'Umidade',
+    temperature: 'Temperatura',
+    filamentType: 'Tipo de Filamento',
+    filamentColor: 'Cor',
+    remaining: 'Restante',
+    history: 'Histórico do AMS',
+    noHistory: 'Nenhum histórico disponível',
+    configureSlot: 'Configurar Slot',
+    externalSpool: 'Carretel Externo',
+    profile: 'Perfil',
+    kFactor: 'Fator K',
+    fill: 'Preencher',
+    configure: 'Configurar',
+  },
+
+  // Print modal
+  printModal: {
+    title: 'Iniciar Impressão',
+    selectPrinter: 'Selecionar Impressora',
+    selectPlate: 'Selecionar Placa',
+    filamentMapping: 'Mapeamento de Filamento',
+    printSettings: 'Configurações de Impressão',
+    bedLeveling: 'Nivelamento da Mesa',
+    flowCalibration: 'Calibração de Fluxo',
+    vibrationCalibration: 'Calibração de Vibração',
+    layerInspection: 'Inspeção da Primeira Camada',
+    timelapse: 'Timelapse',
+    startPrint: 'Iniciar Impressão',
+    addToQueue: 'Adicionar à Fila',
+    cancel: 'Cancelar',
+    noPrintersAvailable: 'Nenhuma impressora disponível',
+    printerBusy: 'Impressora ocupada',
+    printerOffline: 'Impressora offline',
+    sameTypeDifferentColor: 'Mesmo tipo, cor diferente',
+    filamentTypeNotLoaded: 'Tipo de filamento não carregado',
+    openCalendar: 'Abrir calendário',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: 'Bico esquerdo',
+    rightNozzleTooltip: 'Bico direito',
+  },
+
+  // Backup
+  backup: {
+    title: 'Bakup e Restauração',
+    createBackup: 'Criar Backup',
+    restoreBackup: 'Restaurar Backup',
+    restoreDescription: 'Substituir todos os dados a partir de um arquivo de backup',
+    downloadBackup: 'Baixar Backup',
+    uploadBackup: 'Enviar Backup',
+    lastBackup: 'Último Backup',
+    autoBackup: 'Auto Backup',
+    backupNow: 'Fazer Backup Agora',
+    restoreWarning: 'Aviso: Restaurar um backup substituirá todos os dados atuais.',
+    includeArchives: 'Incluir Arquivos',
+    includeSettings: 'Incluir Configurações',
+    includeProfiles: 'Incluir Perfis',
+    backupSuccess: 'Backup criado com sucesso',
+    restoreSuccess: 'Backup restaurado com sucesso',
+    backupFailed: 'Falha ao criar backup',
+    restoreFailed: 'Falha ao restaurar backup',
+    restoreNote: 'A impressora virtual será parada durante a restauração',
+  },
+
+  // Tags
+  tags: {
+    title: 'Tags',
+    addTag: 'Adicionar Tag',
+    editTag: 'Editar Tag',
+    deleteTag: 'Excluir Tag',
+    tagName: 'Nome da Tag',
+    tagColor: 'Cor da Tag',
+    noTags: 'Nenhuma tag',
+    deleteConfirm: 'Tem certeza de que deseja excluir esta tag?',
+    manageTags: 'Gerenciar Tags',
+  },
+
+  // Upload modal (archives)
+  uploadModal: {
+    title: 'Upload Arquivos 3MF',
+    dragDrop: 'Arraste e solte arquivos .3mf aqui',
+    or: 'ou',
+    browseFiles: 'Procurar Arquivos',
+    extractionInfo: 'O modelo da impressora será extraído automaticamente dos metadados do arquivo 3MF.',
+    uploaded: 'enviado',
+    failed: 'falhou',
+    uploading: 'Enviando...',
+    upload: 'Enviar',
+    uploadFailed: 'Falha no envio',
+  },
+
+  // Edit archive modal
+  // Edit Archive Modal
+  editArchive: {
+    title: 'Editar Arquivo',
+    name: 'Nome',
+    namePlaceholder: 'Nome da impressão',
+    printer: 'Impressora',
+    noPrinter: 'Nenhuma impressora',
+    project: 'Projeto',
+    noProject: 'Nenhum projeto',
+    itemsPrinted: 'Itens Impressos',
+    itemsPrintedHelp: 'Número de itens produzidos neste trabalho de impressão',
+    notes: 'Notas',
+    notesPlaceholder: 'Adicione notas sobre esta impressão...',
+    externalLink: 'Link Externo',
+    externalLinkPlaceholder: 'https://printables.com/model/...',
+    externalLinkHelp: 'Link para Printables, Thingiverse ou outra fonte',
+    tags: 'Tags',
+    tagsPlaceholder: 'Adicionar tags...',
+    addMoreTags: 'Adicionar mais tags...',
+    matchingTags: 'Correspondendo "{{query}}"',
+    existingTags: 'Tags existentes',
+    clickToAdd: '(clique para adicionar)',
+    status: 'Status',
+    failureReason: 'Motivo da Falha',
+    selectReason: 'Selecione o motivo...',
+    photos: 'Fotos do Resultado da Impressão',
+    photosHelp: 'Clique em + para adicionar fotos do seu resultado impresso',
+    printResult: 'Resultado da Impressão',
+    saving: 'Salvando...',
+    // Failure reasons
+    failureReasons: {
+      adhesionFailure: 'Falha de adesão',
+      spaghettiDetached: 'Spaghetti / Destacado',
+      layerShift: 'Deslocamento de camada',
+      cloggedNozzle: 'Bico entupido',
+      filamentRunout: 'Fim do filamento',
+      warping: 'Warping',
+      stringing: 'Stringing',
+      underExtrusion: 'Under-extrusion',
+      powerFailure: 'Falha de energia',
+      userCancelled: 'Cancelado pelo usuário',
+      other: 'Outro',
+    },
+    // Archive statuses
+    statuses: {
+      completed: 'Concluído',
+      failed: 'Falhou',
+      aborted: 'Cancelado',
+      printing: 'Imprimindo',
+    },
+  },
+
+  // K-Profiles
+  kProfiles: {
+    title: 'K-Profiles',
+    noPrintersConfigured: 'Nenhuma impressora configurada',
+    addPrinterInSettings: 'Adicione uma impressora nas Configurações para gerenciar K-profiles',
+    noActivePrinters: 'Nenhuma impressora ativa',
+    enablePrinterConnection: 'Ative a conexão da impressora para visualizar seus K-profiles',
+    loadingProfiles: 'Carregando K-Profiles...',
+    printerOffline: 'Impressora Offline',
+    printerOfflineDesc: 'A impressora selecionada não está conectada. Ligue-a para visualizar os K-profiles.',
+    noMatchingProfiles: 'Nenhum Perfil Correspondente',
+    noMatchingProfilesDesc: 'Nenhum perfil corresponde aos seus critérios de pesquisa',
+    noKProfiles: 'Nenhum K-Profile',
+    noKProfilesDesc: 'Nenhum perfil de avanço de pressão encontrado para bico de {{diameter}}mm',
+    createFirstProfile: 'Criar Primeiro Perfil',
+    // Controls
+    printer: 'Impressora',
+    nozzle: 'Bico',
+    refresh: 'Atualizar',
+    addProfile: 'Adicionar Perfil',
+    export: 'Exportar',
+    import: 'Importar',
+    select: 'Selecionar',
+    selectAll: 'Selecionar Todos',
+    delete: 'Excluir',
+    // Filters
+    searchPlaceholder: 'Pesquisar por nome ou filamento...',
+    allExtruders: 'Todos os Extrusores',
+    leftOnly: 'Apenas Esquerdo',
+    rightOnly: 'Apenas Direito',
+    allFlow: 'Todo Fluxo',
+    hfOnly: 'Apenas HF',
+    sOnly: 'Apenas S',
+    sortName: 'Ordenar: Nome',
+    sortKValue: 'Ordenar: Valor K',
+    sortFilament: 'Ordenar: Filamento',
+    // Dual extruder labels
+    leftExtruder: 'Extrusor Esquerdo',
+    rightExtruder: 'Extrusor Direito',
+    // Modal
+    modal: {
+      addTitle: 'Adicionar K-Profile',
+      editTitle: 'Editar K-Profile',
+      profileName: 'Nome do Perfil',
+      profileNamePlaceholder: 'Meu Perfil PLA',
+      kValue: 'Valor K',
+      kValuePlaceholder: '0.020',
+      kValueHelp: 'Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG',
+      filament: 'Filamento',
+      selectFilament: 'Selecionar filamento...',
+      noFilamentsHelp: 'Nenhum filamento encontrado. Crie um K-profile no Bambu Studio primeiro.',
+      flowType: 'Tipo de Fluxo',
+      highFlow: 'Alto Fluxo',
+      standard: 'Padrão',
+      nozzleSize: 'Tamanho do Bico',
+      extruder: 'Extrusor',
+      extruders: 'Extrusores',
+      left: 'Esquerdo',
+      right: 'Direito',
+      notes: 'Notas (armazenadas localmente)',
+      notesPlaceholder: 'Adicione notas sobre este perfil...',
+      notesHelp: 'As notas são salvas no Bambuddy, não na impressora',
+      syncing: 'Sincronizando com a impressora...',
+      savingExtruder: 'Salvando no extrusor {{current}}/{{total}}...',
+      pleaseWait: 'Por favor, aguarde',
+    },
+    // Delete confirmation
+    deleteConfirm: {
+      title: 'Excluir Perfil',
+      cannotUndo: 'Isso não pode ser desfeito',
+      message: 'Tem certeza de que deseja excluir "{{name}}" da impressora?',
+    },
+    // Bulk delete
+    bulkDelete: {
+      title: 'Excluir Perfis',
+      cannotUndo: 'Isso não pode ser desfeito',
+      message: 'Tem certeza de que deseja excluir {{count}} perfis selecionados da impressora?',
+    },
+    // Toast
+    toast: {
+      profileSaved: 'K-profile salvo',
+      profilesSaved: 'K-profile salvo em {{count}} extrusores',
+      selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',
+      profileDeleted: 'K-profile excluído',
+      profilesDeleted: '{{count}} perfis excluídos',
+      exportedProfiles: '{{count}} perfis exportados',
+      importedProfiles: '{{count}} de {{total}} perfis importados',
+      noProfilesToExport: 'Nenhum perfil para exportar',
+      invalidFileFormat: 'Formato de arquivo inválido',
+      failedToParseImport: 'Falha ao analisar o arquivo de importação',
+      failedToSaveBatch: 'Falha ao salvar K-profiles',
+      noteSaved: 'Nota salva',
+      failedToSaveNote: 'Falha ao salvar nota',
+    },
+    // Permissions
+    permission: {
+      noRead: 'Você não tem permissão para atualizar perfis',
+      noCreate: 'Você não tem permissão para adicionar perfis',
+      noUpdate: 'Você não tem permissão para atualizar K-profiles',
+      noDelete: 'Você não tem permissão para excluir K-profiles',
+      noExport: 'Você não tem permissão para exportar perfis',
+      noImport: 'Você não tem permissão para importar perfis',
+    },
+  },
+
+  // Virtual Printer
+  virtualPrinter: {
+    title: 'Impressora Virtual',
+    running: 'Em execução',
+    stopped: 'Parada',
+    description: {
+      default: 'Ative uma impressora virtual que aparece no Bambu Studio e no OrcaSlicer. Os arquivos enviados para esta impressora serão arquivados diretamente sem impressão.',
+      proxy: 'Ative um proxy que retransmite o tráfego do slicer para uma impressora real, permitindo impressão remota em qualquer rede.',
+    },
+    enable: {
+      title: 'Ativar Impressora Virtual',
+      visibleInSlicer: 'Visível como "Bambuddy" na descoberta do slicer',
+      proxyingTo: 'Proxy para {{name}}',
+      notActive: 'Não ativo',
+    },
+    model: {
+      title: 'Modelo da Impressora',
+      description: 'Selecione qual modelo de impressora emular.',
+      restartWarning: 'Alterar o modelo reiniciará a impressora virtual',
+    },
+    accessCode: {
+      title: 'Código de acesso',
+      isSet: 'O código de acesso está definido',
+      notSet: 'Nenhum código de acesso definido — necessário para ativar.',
+      placeholder: 'Digite um código de 8 caracteres',
+      placeholderChange: 'Digite um novo código para alterar',
+      hint: 'Deve ter exatamente 8 caracteres. Usado pelos slicers para autenticação.',
+      charCount: '({{count}}/8)',
+    },
+    targetPrinter: {
+      title: 'Impressora Alvo',
+      configured: 'Proxy alvo configurado',
+      notConfigured: 'Nenhuma impressora alvo selecionada - necessário para o modo proxy',
+      placeholder: 'Selecione uma impressora...',
+      hint: 'Selecione a impressora para a qual o tráfego do slicer será enviado. A impressora deve estar no modo LAN.',
+      noPrinters: 'Nenhuma impressora configurada. Adicione uma impressora primeiro para usar o modo proxy.',
+    },
+    remoteInterface: {
+      title: 'Substituição da Interface de Rede',
+      configured: 'Substituição da interface ativa',
+      optional: 'Opcional - use se o IP detectado automaticamente estiver errado (por exemplo, várias NICs, Docker, VPN)',
+      placeholder: 'Detecção automática (padrão)...',
+      hint: 'Substitua o endereço IP anunciado via SSDP e usado no certificado TLS. Útil quando o Bambuddy possui várias interfaces de rede.',
+    },
+    mode: {
+      title: 'Modo',
+      archive: 'Arquivar',
+      archiveDesc: 'Arquivar arquivos imediatamente',
+      review: 'Revisar',
+      reviewDesc: 'Revisar antes de arquivar',
+      queue: 'Fila',
+      queueDesc: 'Arquivar e adicionar à fila',
+      proxy: 'Proxy',
+      proxyDesc: 'Retransmitir para impressora real',
+    },
+    setupRequired: {
+      title: 'Configuração Necessária',
+      description: 'O recurso de impressora virtual requer configuração adicional do sistema antes de funcionar. Isso inclui encaminhamento de portas, regras de firewall e configurações específicas da plataforma.',
+      readGuide: 'Leia o guia de configuração antes de ativar',
+    },
+    howItWorks: {
+      title: 'Como funciona',
+      titleProxy: 'Como funciona (Modo Proxy)',
+      step1: 'Complete o guia de configuração para sua plataforma',
+      step2: 'Ative a impressora virtual e defina um código de acesso',
+      step3: 'No Bambu Studio ou OrcaSlicer, vá para "Adicionar Impressora"',
+      step4: 'A impressora "Bambuddy" deve aparecer na lista de descoberta',
+      step5: 'Conecte-se usando o código de acesso que você definiu',
+      step6: 'Quando você "imprimir" para o Bambuddy, o arquivo 3MF será arquivado em vez disso',
+      proxyStep1: 'Selecione a impressora alvo (deve estar no modo LAN)',
+      proxyStep2: 'Para rede cruzada: selecione a interface de rede do slicer',
+      proxyStep3: 'Ative o proxy - a impressora aparece na descoberta do slicer via SSDP',
+      proxyStep4: 'Conecte-se usando o código de acesso da impressora',
+      proxyStep5: 'Imprima normalmente - o tráfego é retransmitido pelo Bambuddy',
+      proxyStep6: 'O streaming da câmera requer NAT/encaminhamento de IP (veja a documentação)',
+    },
+    status: {
+      title: 'Detalhes do Status',
+      printerName: 'Nome da Impressora',
+      model: 'Modelo',
+      serialNumber: 'Número de Série',
+      mode: 'Modo',
+      pendingFiles: 'Arquivos Pendentes',
+      targetPrinter: 'Impressora Alvo',
+      ftpPort: 'Porta FTP',
+      mqttPort: 'Porta MQTT',
+      ftpConnections: 'Conexões FTP',
+      mqttConnections: 'Conexões MQTT',
+    },
+    toast: {
+      updated: 'Configurações da impressora virtual atualizadas',
+      failedToUpdate: 'Falha ao atualizar as configurações',
+      accessCodeRequired: 'Defina um código de acesso primeiro',
+      targetPrinterRequired: 'Selecione uma impressora alvo primeiro',
+      accessCodeEmpty: 'O código de acesso não pode estar vazio',
+      accessCodeLength: 'O código de acesso deve ter exatamente 8 caracteres',
+    },
+  },
+
+  // Model Viewer
+  modelViewer: {
+    openInSlicer: 'Abrir no Slicer',
+    tabs: {
+      model: 'Modelo 3D',
+      gcode: 'Pré-visualização G-code',
+    },
+    notAvailable: 'Não disponível',
+    notSliced: 'Não fatiado',
+    plates: 'Placas',
+    allPlates: 'Todas as Placas',
+    plateNumber: 'Placa {{number}}',
+    plateCount: '{{count}} placa',
+    plateCount_other: '{{count}} placas',
+    objectCount: '{{count}} objeto',
+    objectCount_other: '{{count}} objetos',
+    filamentCount: '{{count}} filamento',
+    filamentCount_other: '{{count}} filamentos',
+    eta: 'ETA {{minutes}} min',
+    noPreview: 'Pré-visualização não disponível para este arquivo',
+    pagination: {
+      pageOf: 'Página {{current}} de {{total}}',
+      prev: 'Anterior',
+      next: 'Próximo',
+    },
+    errors: {
+      failedToLoad: 'Falha ao carregar o arquivo',
+      noMeshes: 'Nenhuma malha encontrada no arquivo 3MF',
+      unsupportedFormat: 'Formato de arquivo não suportado',
+    },
+  },
+
+  // Maintenance type descriptions (built-in)
+  maintenanceDescriptions: {
+    lubricateCarbonRods: 'Aplique lubrificante nos eixos de carbono para um movimento suave',
+    lubricateRails: 'Aplique lubrificante nos trilhos lineares para um movimento suave',
+    cleanNozzle: 'Limpe o hotend e o bico para evitar entupimentos',
+    checkBelts: 'Verifique a tensão das correias para impressões precisas',
+    cleanBuildPlate: 'Limpe a placa de construção para melhor adesão',
+    checkExtruder: 'Verifique as engrenagens do extrusor quanto ao desgaste',
+    checkCooling: 'Verifique se os ventiladores de resfriamento estão funcionando corretamente',
+    generalInspection: 'Inspeção geral da impressora',
+    cleanCarbonRods: 'Limpe os eixos de carbono para reduzir o atrito',
+    cleanLinearRails: 'Limpe os trilhos lineares para remover poeira e detritos',
+    checkPtfeTube: 'Verifique o tubo PTFE quanto ao desgaste ou danos',
+    replaceHepaFilter: 'Substitua o filtro HEPA para qualidade do ar',
+    replaceCarbonFilter: 'Substitua o filtro de carbono ativado',
+    lubricateLeftNozzleRail: 'Lubrifique o trilho do bico esquerdo (série H2)',
+  },
+
+  // Smart Plugs
+  smartPlugs: {
+    offline: 'Offline',
+    admin: 'Admin',
+    openPlugAdminPage: 'Abrir o painel de administração da tomada inteligente',
+    deleteSmartPlug: 'Excluir Tomada Inteligente',
+    turnOnSmartPlug: 'Ligar Tomada Inteligente',
+    turnOffSmartPlug: 'Desligar Tomada Inteligente',
+    turnOn: 'Ligar',
+    turnOff: 'Desligar',
+    addSmartPlug: {
+      scanningNetwork: 'Procurando na rede...',
+      chooseEntity: 'Escolha uma entidade...',
+      connectionFailed: 'Falha na conexão',
+      searchEntities: 'Pesquisar entidades...',
+      searchPowerSensors: 'Pesquisar sensores de energia...',
+      searchEnergySensors: 'Pesquisar sensores de energia...',
+      placeholders: {
+        plugName: 'Tomada da Sala',
+        mqttStateOnValue: 'ON, true, 1',
+        mqttSameAsPower: 'Mesmo que o tópico de energia, ou diferente',
+      },
+    },
+  },
+
+  // Rich Text Editor
+  richTextEditor: {
+    bold: 'Negrito',
+    italic: 'Itálico',
+    underline: 'Sublinhado',
+    bulletList: 'Lista com marcadores',
+    numberedList: 'Lista numerada',
+    alignLeft: 'Alinhar à esquerda',
+    alignCenter: 'Centralizar',
+    alignRight: 'Alinhar à direita',
+    addLink: 'Adicionar link',
+    removeLink: 'Remover link',
+  },
+
+  // External Links
+  externalLinks: {
+    noLinksConfigured: 'Nenhum link externo configurado',
+    deleteLink: 'Excluir link',
+    removeCustomIcon: 'Remover ícone personalizado',
+    openInNewTab: 'Abrir em nova aba',
+    placeholders: {
+      linkName: 'Meu Link',
+    },
+  },
+
+  // Keyboard Shortcuts Modal
+  keyboardShortcuts: {
+    title: 'Atalhos de Teclado',
+    navigation: 'Navegação',
+    archivesSection: 'Arquivos',
+    kProfilesSection: 'K-Profiles',
+    generalSection: 'Geral',
+    shortcuts: {
+      goToPrinters: 'Ir para Impressoras',
+      goToArchives: 'Ir para Arquivos',
+      goToQueue: 'Ir para Fila',
+      goToStats: 'Ir para Estatísticas',
+      goToProfiles: 'Ir para Perfis na Nuvem',
+      goToSettings: 'Ir para Configurações',
+      focusSearch: 'Focar na pesquisa',
+      openUploadModal: 'Abrir modal de upload',
+      clearSelection: 'Limpar seleção / desfocar input',
+      contextMenu: 'Menu de contexto nos cartões',
+      refreshProfiles: 'Atualizar perfis',
+      newProfile: 'Novo perfil',
+      exitSelectionMode: 'Sair do modo de seleção',
+      showHelp: 'Mostrar esta ajuda',
+    },
+    footer: 'Pressione Esc ou clique fora para fechar',
+  },
+
+  // Notification Log
+  notificationLog: {
+    title: 'Registro de Notificações',
+    events: {
+      printStarted: 'Impressão Iniciada',
+      printComplete: 'Impressão Concluída',
+      printFailed: 'Impressão Falhou',
+      printStopped: 'Impressão Interrompida',
+      progress: 'Progresso',
+      printerOffline: 'Impressora Offline',
+      printerError: 'Erro na Impressora',
+      lowFilament: 'Filamento Baixo',
+      maintenanceDue: 'Manutenção Pendente',
+      test: 'Teste',
+    },
+    timeAgo: {
+      justNow: 'Agora mesmo',
+      minutesAgo: 'há {{minutes}} minutos',
+      hoursAgo: 'há {{hours}} horas',
+    },
+  },
+
+  // Restore/Backup Modal
+  restoreBackup: {
+    title: 'Restaurar Backup',
+    restoring: 'Restaurando...',
+    restoreComplete: 'Restauração Concluída',
+    restoreFailed: 'Falha na Restauração',
+    importSettings: 'Importar configurações de um arquivo de backup',
+    pleaseWait: 'Aguarde enquanto seus dados estão sendo restaurados',
+    clickToSelect: 'Clique para selecionar o arquivo de backup (.json ou .zip)',
+    howDuplicateHandling: 'Como funciona o tratamento de duplicatas:',
+    categories: {
+      printers: 'Impressoras',
+      smartPlugs: 'Tomadas Inteligentes',
+      notificationProviders: 'Provedores de Notificação',
+      filaments: 'Filamentos',
+      archives: 'Arquivos',
+      pendingUploads: 'Uploads Pendentes',
+      settingsTemplates: 'Configurações e Modelos',
+    },
+    matchingInfo: {
+      printers: 'correspondem pelo número de série',
+      smartPlugs: 'correspondem pelo endereço IP',
+      notificationProviders: 'correspondem pelo nome',
+      filaments: 'correspondem pelo nome + tipo + marca',
+      archives: 'correspondem pelo hash do conteúdo',
+      pendingUploads: 'correspondem pelo nome do arquivo',
+      settingsTemplates: 'sempre sobrescrito',
+    },
+    replaceExisting: 'Substituir dados existentes',
+    keepExisting: 'Manter dados existentes',
+    replaceDescription: 'Substituir itens que já existem com os dados do backup',
+    keepDescription: 'Restaurar apenas itens que não existem',
+    caution: 'Atenção:',
+    cautionText: 'Sobrescrever substituirá suas configurações atuais pelos dados do backup. Os códigos de acesso da impressora nunca são sobrescritos por segurança.',
+    itemsRestored: 'Itens Restaurados',
+    itemsSkipped: 'Itens Ignorados',
+    restored: 'Restaurado',
+    skipped: 'Ignorado (já existe)',
+    filesLabel: 'Arquivos (3MF, miniaturas, etc.)',
+    newApiKeysGenerated: 'Novas Chaves API Geradas',
+    newApiKeysWarning: 'Essas chaves são exibidas apenas uma vez. Copie-as agora!',
+    processingBackup: 'Processando arquivo de backup...',
+    noDataFound: 'Nenhum dado foi encontrado para restaurar no arquivo de backup.',
+    failedToRestore: 'Falha ao restaurar o backup. Verifique o formato do arquivo.',
+  },
+
+  // Backup Export Modal
+  backupExport: {
+    title: 'Exportar Backup',
+    selectData: 'Selecione os dados a incluir',
+    selectAll: 'Selecionar Todos',
+    selectNone: 'Selecionar Nenhum',
+    categoryDescriptions: {
+      settings: 'Idioma, tema, preferências de atualização',
+      notifications: 'ntfy, Pushover, Discord, etc.',
+      templates: 'Modelos de mensagens personalizadas',
+      smartPlugs: 'Configurações de tomadas Tasmota',
+      externalLinks: 'Links da barra lateral para serviços externos',
+      printers: 'Informações da impressora (códigos de acesso excluídos)',
+      plateDetection: 'Imagens de referência de placa vazia',
+      filaments: 'Tipos e custos de filamento',
+      maintenance: 'Cronogramas de manutenção personalizados',
+      archives: 'Todos os dados de impressão + arquivos (3MF, miniaturas, fotos)',
+      projects: 'Projetos, itens de BOM e anexos',
+      pendingUploads: 'Uploads de impressora virtual aguardando revisão',
+      apiKeys: 'Chaves API de webhook (novas chaves geradas na importação)',
+    },
+    requiresPrinters: 'Requer que as impressoras sejam selecionadas',
+    zipFileWarning: 'Um arquivo ZIP será criado.',
+    zipFileDescription: 'Inclui todos os arquivos 3MF, miniaturas, timelapses e fotos. Isso pode levar algum tempo e resultar em um arquivo grande.',
+    includeAccessCodes: 'Incluir Códigos de Acesso',
+    includeAccessCodesDescription: 'Para transferir para outra máquina',
+    includeAccessCodesWarning: 'Os códigos de acesso serão incluídos em texto simples. Mantenha este arquivo de backup seguro!',
+    categoriesSelected: '{{selectedCount}} categorias selecionadas',
+  },
+
+  // Pending Uploads Panel
+  pendingUploads: {
+    placeholders: {
+      notes: 'Adicione notas sobre esta impressão...',
+    },
+    discardUpload: 'Descartar Upload',
+    archiveAllUploads: 'Arquivar Todos os Uploads',
+    discardAllUploads: 'Descartar Todos os Uploads',
+    archive: 'Arquivar',
+    timeAgo: {
+      justNow: 'Agora mesmo',
+      minutesAgo: 'há {{minutes}} minutos',
+      hoursAgo: 'há {{hours}} horas',
+      daysAgo: 'há {{days}} dias',
+    },
+  },
+
+  // API Browser
+  apiBrowser: {
+    placeholders: {
+      requestBody: 'Corpo da requisição JSON...',
+      searchEndpoints: 'Pesquisar endpoints...',
+    },
+  },
+
+  // Configure AMS Slot Modal
+  configureAmsSlot: {
+    title: 'Configurar Slot AMS',
+    slotConfigured: 'Slot Configurado!',
+    configuringSlot: 'Configurando slot:',
+    slotLabel: '{{ams}} Slot {{slot}}',
+    searchPresets: 'Pesquisar predefinições...',
+    colorPlaceholder: 'Nome da cor ou hex (ex.: marrom, FF8800)',
+    clearCustomColor: 'Limpar cor personalizada',
+    noCloudPresets: 'Nenhuma predefinição na nuvem. Faça login no Bambu Cloud para sincronizar.',
+    noPresetsAvailable: 'Nenhuma predefinição disponível. Faça login no Bambu Cloud ou importe perfis locais.',
+    noMatchingPresets: 'Nenhuma predefinição correspondente encontrada.',
+    custom: 'Personalizado',
+    builtin: 'Integrado',
+    settingsSentToPrinter: 'Configurações enviadas para a impressora',
+    filamentProfile: 'Perfil de Filamento',
+    kProfileLabel: 'Perfil K (Avanço de Pressão)',
+    filteringFor: 'Filtrando por: {{material}}',
+    noKProfile: 'Nenhum perfil K (usar padrão 0.020)',
+    noMatchingKProfiles: 'Nenhum perfil K correspondente encontrado. O K padrão=0.020 será usado.',
+    selectFilamentFirst: 'Selecione um perfil de filamento primeiro',
+    kFromCalibration: 'K={{value}} da calibração da impressora',
+    customColorLabel: 'Cor Personalizada (opcional)',
+    presetColors: 'Cores de {{name}}:',
+    showLessColors: 'Mostrar menos cores',
+    showMoreColors: 'Mostrar mais cores',
+    clear: 'Limpar',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'Redefinindo...',
+    resetSlot: 'Redefinir Slot',
+    cancel: 'Cancelar',
+    configuring: 'Configurando...',
+    configureSlot: 'Configurar Slot',
+  },
+
+  // GitHub Backup Settings
+  githubBackup: {
+    title: 'Backup do GitHub',
+    history: 'Histórico',
+    downloadBackup: 'Baixar Backup',
+    restoreBackup: 'Restaurar Backup',
+    noBackupsYet: 'Nenhum backup ainda',
+  },
+
+  // Email Settings
+  emailSettings: {
+    placeholders: {
+      fromName: 'BamBuddy',
+    },
+  },
+
+  // Tag Management Modal
+  tagManagement: {
+    searchTags: 'Pesquisar tags...',
+    renameTag: 'Renomear tag',
+    deleteTag: 'Excluir tag',
+  },
+
+  // Notification Template Editor
+  notificationTemplates: {
+    placeholders: {
+      title: 'Título da notificação...',
+      body: 'Corpo da notificação...',
+    },
+  },
+
+  // Batch Tag Modal
+  batchTag: {
+    placeholders: {
+      newTag: 'Digite uma nova tag...',
+    },
+  },
+
+  // Photo Gallery Modal
+  photoGallery: {
+    deletePhoto: 'Excluir foto',
+  },
+
+  // Filament Hover Card
+  filamentHoverCard: {
+    copySpoolUuid: 'Copiar UUID do carretel',
+  },
+
+  // K Profiles View
+  kProfilesView: {
+    hasNote: 'Possui nota',
+    copyProfile: 'Copiar perfil',
+  },
+
+  // Layout/Navigation
+  layout: {
+    openMenu: 'Abrir menu',
+    noPermissionSystemInfo: 'Você não tem permissão para visualizar informações do sistema',
+  },
+
+  // Dashboard
+  dashboard: {
+    dragToReorder: 'Arrastar para reordenar',
+    hideWidget: 'Ocultar widget',
+  },
+
+  // Notification Provider Card
+  notificationProviderCard: {
+    deleteNotificationProvider: 'Excluir provedor de notificação',
+  },
+
+  // File Manager Modal
+  fileManagerModal: {
+    closeFileManager: 'Fechar gerenciador de arquivos',
+    sortFiles: 'Ordenar arquivos',
+    goToParentFolder: 'Ir para a pasta pai',
+    threeView: 'Visualização 3D',
+  },
+
+  // Embedded Camera Viewer
+  embeddedCameraViewer: {
+    refreshStream: 'Atualizar stream',
+    close: 'Fechar',
+    zoomOut: 'Reduzir zoom',
+    resetZoom: 'Redefinir zoom',
+    zoomIn: 'Aumentar zoom',
+    dragToResize: 'Arrastar para redimensionar',
+  },
+
+  // Timelapse Viewer
+  timelapseViewer: {
+    skipBack5s: 'Voltar 5s',
+    skipForward5s: 'Avançar 5s',
+  },
+
+  // Notification Providers
+  notificationProviders: {
+    descriptions: {
+      email: 'Notificações por email SMTP',
+      telegram: 'Notificações via bot do Telegram',
+      discord: 'Enviar para canal do Discord via webhook',
+      ntfy: 'Notificações push gratuitas e auto-hospedáveis',
+      pushover: 'Notificações push simples e confiáveis',
+      callmebot: 'Notificações gratuitas via WhatsApp pelo CallMeBot',
+      webhook: 'POST HTTP genérico para qualquer URL',
+    },
+  },
+
+  // Log Viewer
+  logViewer: {
+    searchPlaceholder: 'Pesquisar mensagem ou nome do logger...',
+    noLogEntries: 'Nenhuma entrada de log encontrada',
+  },
+
+  // Switchbar Popover
+  switchbarPopover: {
+    noSwitchesInSwitchbar: 'Nenhum switch na barra de switches',
+  },
+
+  // Project Page Modal
+  projectPageModal: {
+    placeholders: {
+      title: 'Título',
+      designer: 'Designer',
+      license: 'Licença',
+      description: 'Digite a descrição...',
+      profileTitle: 'Título do perfil',
+      profileDescription: 'Descrição do perfil...',
+    },
+  },
+
+  // Spoolman Settings
+  spoolmanSettings: {},
+};

+ 39 - 22
frontend/src/pages/ArchivesPage.tsx

@@ -74,15 +74,10 @@ import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { TagManagementModal } from '../components/TagManagementModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
+import { formatFileSize } from '../utils/file';
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
-
 /**
  * Check if an archive filename represents a sliced/printable file.
  * Matches: .gcode, .gcode.3mf, .gcode.anything
@@ -105,6 +100,36 @@ function getArchiveFileType(filename: string | null | undefined): string | undef
 
 // formatDate imported from '../utils/date' - handles UTC conversion
 
+/**
+ * Open an archive file in the slicer.
+ * Fetches a short-lived download token, then builds a token-authenticated URL
+ * that bypasses auth middleware (slicer protocol handlers can't send auth headers).
+ */
+async function openInSlicerWithToken(
+  archiveId: number,
+  filename: string,
+  resourceType: 'file' | 'source',
+  slicer: SlicerType,
+): Promise<void> {
+  try {
+    if (resourceType === 'source') {
+      const { token } = await api.createSourceSlicerToken(archiveId);
+      const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    } else {
+      const { token } = await api.createArchiveSlicerToken(archiveId);
+      const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    }
+  } catch {
+    // Fallback to direct URL (works when auth is disabled)
+    const path = resourceType === 'source'
+      ? api.getSource3mfForSlicer(archiveId, filename)
+      : api.getArchiveForSlicer(archiveId, filename);
+    openInSlicer(`${window.location.origin}${path}`, slicer);
+  }
+}
+
 function ArchiveCard({
   archive,
   printerName,
@@ -337,8 +362,7 @@ function ArchiveCard({
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
         disabled: !archive.file_path,
         title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
@@ -349,8 +373,7 @@ function ArchiveCard({
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
       },
     ]),
@@ -738,8 +761,7 @@ function ArchiveCard({
               e.stopPropagation();
               // 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)}`;
-              openInSlicer(downloadUrl, preferredSlicer);
+              openInSlicerWithToken(archive.id, sourceName, 'source', preferredSlicer);
             }}
             title={t('archives.card.openSource3mf')}
           >
@@ -1008,8 +1030,7 @@ function ArchiveCard({
                 className="min-w-0 p-1 sm:p-1.5"
                 onClick={() => {
                   const filename = archive.print_name || archive.filename || 'model';
-                  const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                  openInSlicer(downloadUrl, preferredSlicer);
+                  openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
                 }}
                 title={t('archives.card.openInBambuStudio')}
               >
@@ -1024,8 +1045,7 @@ function ArchiveCard({
               className="flex-1 min-w-0"
               onClick={() => {
                 const filename = archive.print_name || archive.filename || 'model';
-                const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                openInSlicer(downloadUrl, preferredSlicer);
+                openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
               }}
               title={t('archives.card.openInBambuStudioToSlice')}
             >
@@ -1546,8 +1566,7 @@ function ArchiveListRow({
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
         disabled: !archive.file_path,
         title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
@@ -1558,8 +1577,7 @@ function ArchiveListRow({
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
       },
     ]),
@@ -1900,8 +1918,7 @@ function ArchiveListRow({
             size="sm"
             onClick={() => {
               const filename = archive.print_name || archive.filename || 'model';
-              const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-              openInSlicer(downloadUrl, preferredSlicer);
+              openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
             }}
             title={t('archives.card.openInBambuStudio')}
           >

+ 1 - 8
frontend/src/pages/FileManagerPage.tsx

@@ -58,19 +58,12 @@ import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration } from '../utils/date';
+import { formatFileSize } from '../utils/file';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
-// Utility to format file size
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
-}
-
 // New Folder Modal
 interface NewFolderModalProps {
   parentId: number | null;

+ 5 - 2
frontend/src/pages/PrintersPage.tsx

@@ -64,6 +64,7 @@ import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
+import { getGlobalTrayId } from '../utils/amsHelpers';
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -2906,6 +2907,7 @@ function PrinterCard({
                                               material: assignment.spool.material,
                                               brand: assignment.spool.brand,
                                               color_name: assignment.spool.color_name,
+                                              remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                             } : null,
                                             onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                               printerId: printer.id,
@@ -2977,8 +2979,7 @@ function PrinterCard({
                         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 globalTrayId = getGlobalTrayId(ams.id, tray?.id ?? 0, false);
                         const isActive = effectiveTrayNow === globalTrayId;
                         // Get cloud preset info if available
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
@@ -3144,6 +3145,7 @@ function PrinterCard({
                                           material: assignment.spool.material,
                                           brand: assignment.spool.brand,
                                           color_name: assignment.spool.color_name,
+                                          remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                         } : null,
                                         onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                           printerId: printer.id,
@@ -3330,6 +3332,7 @@ function PrinterCard({
                                             material: assignment.spool.material,
                                             brand: assignment.spool.brand,
                                             color_name: assignment.spool.color_name,
+                                            remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                           } : null,
                                           onAssignSpool: () => setAssignSpoolModal({
                                             printerId: printer.id,

+ 2 - 19
frontend/src/pages/ProfilesPage.tsx

@@ -43,7 +43,7 @@ import {
   HardDrive,
 } from 'lucide-react';
 import { api } from '../api/client';
-import { parseUTCDate } from '../utils/date';
+import { formatRelativeTime } from '../utils/date';
 import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -90,23 +90,6 @@ function isUserPreset(settingId: string): boolean {
   return /^(P[FPM]US|PF\d|PP\d)/.test(settingId);
 }
 
-// Format relative time
-function formatRelativeTime(dateStr: string, t: TFunction): string {
-  const date = parseUTCDate(dateStr);
-  if (!date) return '';
-  const now = new Date();
-  const diffMs = now.getTime() - date.getTime();
-  const diffMins = Math.floor(diffMs / 60000);
-  const diffHours = Math.floor(diffMs / 3600000);
-  const diffDays = Math.floor(diffMs / 86400000);
-
-  if (diffMins < 1) return t('profiles.time.justNow');
-  if (diffMins < 60) return t('profiles.time.minsAgo', { count: diffMins });
-  if (diffHours < 24) return t('profiles.time.hoursAgo', { count: diffHours });
-  if (diffDays < 7) return t('profiles.time.daysAgo', { count: diffDays });
-  return date.toLocaleDateString();
-}
-
 // ============================================================================
 // LOGIN FORM
 // ============================================================================
@@ -2646,7 +2629,7 @@ function CloudProfilesView({
         {lastSyncTime && (
           <div className="flex items-center gap-1">
             <Clock className="w-3 h-3" />
-            {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), t)}
+            {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}
           </div>
         )}
         <span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>

+ 200 - 191
frontend/src/pages/QueuePage.tsx

@@ -50,7 +50,7 @@ import {
   Weight,
 } from 'lucide-react';
 import { api } from '../api/client';
-import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration } from '../utils/date';
+import { type TimeFormat, formatETA, formatDuration, formatRelativeTime } from '../utils/date';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -64,21 +64,6 @@ function formatWeight(g: number, useKg = false): string {
   return `${Math.round(g)}g`;
 }
 
-function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
-  if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
-  const date = parseUTCDate(dateString);
-  if (!date) return t?.('queue.time.asap') ?? 'ASAP';
-  const now = new Date();
-  const diff = date.getTime() - now.getTime();
-
-  if (diff < -60000) return t?.('queue.time.overdue') ?? 'Overdue';
-  if (diff < 0) return t?.('queue.time.now') ?? 'Now';
-  if (diff < 60000) return t?.('queue.time.lessThanMinute') ?? 'In less than a minute';
-  if (diff < 3600000) return t?.('queue.time.inMinutes', { count: Math.round(diff / 60000) }) ?? `In ${Math.round(diff / 60000)} min`;
-  if (diff < 86400000) return t?.('queue.time.inHours', { count: Math.round(diff / 3600000) }) ?? `In ${Math.round(diff / 3600000)} hours`;
-  return formatDateTime(dateString, timeFormat);
-}
-
 function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
   // Special case: pending with waiting_reason shows as "Waiting"
   if (status === 'pending' && waitingReason) {
@@ -361,26 +346,40 @@ function SortableQueueItem({
   const isPending = item.status === 'pending';
   const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
 
+  const isMobileSelectable = isPending && onToggleSelect;
+
   return (
     <div
       ref={setNodeRef}
       style={style}
       className={`
-        group relative bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary
-        transition-all duration-200 hover:border-bambu-dark-tertiary/80
+        group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200
         ${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
         ${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
+        ${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
+        ${!isSelected && !isPrinting ? 'border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80' : ''}
+        ${isMobileSelectable ? 'sm:cursor-default' : ''}
       `}
+      onClick={isMobileSelectable ? () => {
+        if (window.innerWidth < 640) onToggleSelect();
+      } : undefined}
     >
-      <div className="flex items-center gap-4 p-4">
-        {/* Selection checkbox for pending items */}
+      {/* Mobile selected left accent bar */}
+      {isMobileSelectable && isSelected && (
+        <div className="sm:hidden absolute left-0 top-3 bottom-3 w-1 rounded-full bg-bambu-green" />
+      )}
+
+      <div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
+        {/* Mobile selection indicator — left accent bar only, no tick */}
+
+        {/* Selection checkbox for pending items - hidden on mobile, tap card instead */}
         {isPending && onToggleSelect && (
           <button
             onClick={(e) => {
               e.stopPropagation();
               onToggleSelect();
             }}
-            className={`flex items-center justify-center w-6 h-6 rounded border transition-colors ${
+            className={`hidden sm:flex items-center justify-center w-6 h-6 rounded border transition-colors shrink-0 ${
               isSelected
                 ? 'bg-bambu-green border-bambu-green text-white'
                 : 'border-white/30 bg-black/30 hover:border-bambu-green/50'
@@ -390,25 +389,25 @@ function SortableQueueItem({
           </button>
         )}
 
-        {/* Drag handle or position number */}
+        {/* Drag handle or position number - hidden on mobile */}
         {isPending ? (
           <div
             {...attributes}
             {...listeners}
-            className="flex items-center justify-center w-10 h-10 md:w-8 md:h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation"
+            className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0"
           >
-            <GripVertical className="w-6 h-6 md:w-4 md:h-4 text-bambu-gray" />
+            <GripVertical className="w-4 h-4 text-bambu-gray" />
           </div>
         ) : position !== undefined ? (
-          <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
+          <div className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium shrink-0">
             #{position}
           </div>
         ) : (
-          <div className="w-8" />
+          <div className="hidden sm:block w-8 shrink-0" />
         )}
 
         {/* Thumbnail - use plate-specific thumbnail if plate_id is set */}
-        <div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
+        <div className="w-10 h-10 sm:w-14 sm:h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
           {item.archive_thumbnail ? (
             <img
               src={
@@ -431,7 +430,7 @@ function SortableQueueItem({
             />
           ) : (
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
-              <Layers className="w-6 h-6" />
+              <Layers className="w-5 h-5 sm:w-6 sm:h-6" />
             </div>
           )}
         </div>
@@ -439,7 +438,7 @@ function SortableQueueItem({
         {/* Info */}
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2 mb-1">
-            <p className="text-white font-medium truncate">
+            <p className="text-sm sm:text-base text-white font-medium truncate">
               {item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`}
               {(platesData?.is_multi_plate ?? false) && item.plate_id !== undefined && item.plate_id !== null && ` • ${plates.find(plate => plate.index === item.plate_id)?.name || t('queue.plateNumber', { index: item.plate_id })}`}
             </p>
@@ -462,29 +461,31 @@ function SortableQueueItem({
             ) : null}
           </div>
 
-          <div className="flex items-center gap-3 text-sm text-bambu-gray">
-            <span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
-              <Printer className="w-3.5 h-3.5" />
+          <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:text-sm text-bambu-gray">
+            <span className={`flex items-center gap-1 sm:gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
+              <Printer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
+              <span className="truncate max-w-[120px] sm:max-w-none">
               {item.target_model
                 ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
                 : item.printer_id === null
                   ? t('queue.filter.unassigned')
                   : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
+              </span>
             </span>
             {item.print_time_seconds && (
-              <span className="flex items-center gap-1.5">
-                <Timer className="w-3.5 h-3.5" />
+              <span className="flex items-center gap-1 sm:gap-1.5">
+                <Timer className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
                 {formatDuration(item.print_time_seconds)}
               </span>
             )}
             {item.filament_used_grams && (
-              <span className="flex items-center gap-1.5">
-                <Weight className="w-3.5 h-3.5" />
+              <span className="flex items-center gap-1 sm:gap-1.5">
+                <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
                 {formatWeight(item.filament_used_grams)}
               </span>
             )}
             {item.created_by_username && (
-              <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
+              <span className="hidden sm:flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
                 <User className="w-3.5 h-3.5" />
                 {item.created_by_username}
               </span>
@@ -492,27 +493,31 @@ function SortableQueueItem({
             {isPending && !item.manual_start && (
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
-                {formatRelativeTime(item.scheduled_time, timeFormat, t)}
+                {item.scheduled_time
+                  ? (new Date(item.scheduled_time).getTime() - Date.now() < -60000
+                      ? t?.('queue.time.overdue') ?? 'Overdue'
+                      : formatRelativeTime(item.scheduled_time, timeFormat, t))
+                  : t?.('queue.time.asap') ?? 'ASAP'}
               </span>
             )}
           </div>
 
           {/* Options badges */}
-          <div className="flex items-center gap-2 mt-2">
+          <div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-1.5 sm:mt-2">
             {item.manual_start && (
-              <span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
-                <Hand className="w-3 h-3" />
+              <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
+                <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
                 {t('queue.badges.staged')}
               </span>
             )}
             {item.require_previous_success && (
-              <span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
+              <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
                 {t('queue.badges.requiresPrevious')}
               </span>
             )}
             {item.auto_off_after && (
-              <span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
-                <Power className="w-3 h-3" />
+              <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
+                <Power className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
                 {t('queue.badges.autoPowerOff')}
               </span>
             )}
@@ -520,17 +525,17 @@ function SortableQueueItem({
 
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}
           {isPrinting && status && (
-            <div className="mt-3">
-              <div className="flex items-center justify-between text-sm">
-                <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
+            <div className="mt-2 sm:mt-3">
+              <div className="flex items-center justify-between text-xs sm:text-sm">
+                <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5 sm:h-2 mr-3">
                   <div
-                    className="bg-bambu-green h-2 rounded-full transition-all"
+                    className="bg-bambu-green h-1.5 sm:h-2 rounded-full transition-all"
                     style={{ width: `${status.progress || 0}%` }}
                   />
                 </div>
                 <span className="text-white">{Math.round(status.progress || 0)}%</span>
               </div>
-              <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
+              <div className="flex flex-wrap items-center gap-2 sm:gap-3 mt-1.5 sm:mt-2 text-[10px] sm:text-xs text-bambu-gray">
                 {status.remaining_time != null && status.remaining_time > 0 && (
                   <>
                     <span className="flex items-center gap-1">
@@ -554,7 +559,7 @@ function SortableQueueItem({
 
           {/* Waiting reason for model-based assignments */}
           {item.waiting_reason && item.status === 'pending' && (
-            <p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
+            <p className="text-[10px] sm:text-xs text-purple-400 mt-1.5 sm:mt-2 flex items-start gap-1">
               <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
               <span>{item.waiting_reason}</span>
             </p>
@@ -562,88 +567,91 @@ function SortableQueueItem({
 
           {/* Error message */}
           {item.error_message && (
-            <p className="text-xs text-red-400 mt-2 flex items-center gap-1">
+            <p className="text-[10px] sm:text-xs text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">
               <AlertCircle className="w-3 h-3" />
               {item.error_message}
             </p>
           )}
         </div>
 
-        {/* Status badge */}
-        <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
-
-        {/* Actions */}
-        <div className="flex items-center gap-1">
-          {isPrinting && (
-            <Button
-              variant="ghost"
-              size="sm"
-              onClick={onStop}
-              disabled={!hasPermission('printers:control')}
-              title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
-              className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
-            >
-              <StopCircle className="w-4 h-4" />
-            </Button>
-          )}
-          {isPending && (
-            <>
-              {item.manual_start && (
-                <Button
-                  variant="ghost"
-                  size="sm"
-                  onClick={onStart}
-                  disabled={!hasPermission('printers:control')}
-                  title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
-                  className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
-                >
-                  <Play className="w-4 h-4" />
-                </Button>
-              )}
-              <Button
-                variant="ghost"
-                size="sm"
-                onClick={onEdit}
-                disabled={!canModify('queue', 'update', item.created_by_id)}
-                title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
-              >
-                <Pencil className="w-4 h-4" />
-              </Button>
-              <Button
-                variant="ghost"
-                size="sm"
-                onClick={onCancel}
-                disabled={!canModify('queue', 'delete', item.created_by_id)}
-                title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
-                className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
-              >
-                <X className="w-4 h-4" />
-              </Button>
-            </>
-          )}
-          {isHistory && (
-            <>
-              <Button
-                variant="ghost"
-                size="sm"
-                onClick={onRequeue}
-                disabled={!hasPermission('queue:create')}
-                title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
-                className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
-              >
-                <RefreshCw className="w-4 h-4" />
-              </Button>
+        {/* Status badge + Actions */}
+        <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
+          <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
+
+          <div className="flex items-center gap-0.5 sm:gap-1">
+            {isPrinting && (
               <Button
                 variant="ghost"
                 size="sm"
-                onClick={onRemove}
-                disabled={!canModify('queue', 'delete', item.created_by_id)}
-                title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
+                onClick={onStop}
+                disabled={!hasPermission('printers:control')}
+                title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
+                className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
               >
-                <Trash2 className="w-4 h-4" />
+                <StopCircle className="w-4 h-4" />
               </Button>
-            </>
-          )}
+            )}
+            {isPending && (
+              <>
+                {item.manual_start && (
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={onStart}
+                    disabled={!hasPermission('printers:control')}
+                    title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
+                    className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
+                  >
+                    <Play className="w-4 h-4" />
+                  </Button>
+                )}
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onEdit}
+                  disabled={!canModify('queue', 'update', item.created_by_id)}
+                  title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
+                  className="p-1.5 sm:p-2"
+                >
+                  <Pencil className="w-4 h-4" />
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onCancel}
+                  disabled={!canModify('queue', 'delete', item.created_by_id)}
+                  title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
+                  className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
+                >
+                  <X className="w-4 h-4" />
+                </Button>
+              </>
+            )}
+            {isHistory && (
+              <>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onRequeue}
+                  disabled={!hasPermission('queue:create')}
+                  title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
+                  className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5 sm:p-2"
+                >
+                  <RefreshCw className="w-4 h-4" />
+                </Button>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={onRemove}
+                  disabled={!canModify('queue', 'delete', item.created_by_id)}
+                  title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
+                  className="p-1.5 sm:p-2"
+                >
+                  <Trash2 className="w-4 h-4" />
+                </Button>
+              </>
+            )}
+          </div>
         </div>
       </div>
     </div>
@@ -987,72 +995,72 @@ export function QueuePage() {
       </div>
 
       {/* Summary Cards */}
-      <div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
+      <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 sm:gap-3 lg:gap-4 mb-8">
         <Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
-                <Play className="w-5 h-5 text-blue-400" />
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0">
+                <Play className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{activeItems.length}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.printing')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{activeItems.length}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.printing')}</p>
               </div>
             </div>
           </CardContent>
         </Card>
 
         <Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
-                <Clock className="w-5 h-5 text-yellow-400" />
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center shrink-0">
+                <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{pendingItems.length}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.queued')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{pendingItems.length}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.queued')}</p>
               </div>
             </div>
           </CardContent>
         </Card>
 
         <Card className="bg-gradient-to-br from-bambu-green/10 to-transparent border-bambu-green/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center">
-                <Timer className="w-5 h-5 text-bambu-green" />
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center shrink-0">
+                <Timer className="w-4 h-4 sm:w-5 sm:h-5 text-bambu-green" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.totalTime')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{formatDuration(totalQueueTime)}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.totalTime')}</p>
               </div>
             </div>
           </CardContent>
         </Card>
 
         <Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
-                <Weight className="w-5 h-5 text-purple-500" />
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
+                <Weight className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{formatWeight(totalWeight)}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.totalWeight')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{formatWeight(totalWeight)}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.totalWeight')}</p>
               </div>
             </div>
           </CardContent>
         </Card>
 
-        <Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
-          <CardContent className="p-4">
-            <div className="flex items-center gap-3">
-              <div className="w-10 h-10 rounded-lg bg-gray-500/20 flex items-center justify-center">
-                <CheckCircle className="w-5 h-5 text-gray-400" />
+        <Card className="col-span-2 sm:col-span-1 bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
+          <CardContent className="p-3 sm:p-4">
+            <div className="flex items-center gap-2 sm:gap-3">
+              <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-gray-500/20 flex items-center justify-center shrink-0">
+                <CheckCircle className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />
               </div>
-              <div>
-                <p className="text-2xl font-bold text-white">{historyItems.length}</p>
-                <p className="text-sm text-bambu-gray">{t('queue.summary.history')}</p>
+              <div className="min-w-0">
+                <p className="text-xl sm:text-2xl font-bold text-white truncate">{historyItems.length}</p>
+                <p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.history')}</p>
               </div>
             </div>
           </CardContent>
@@ -1060,9 +1068,9 @@ export function QueuePage() {
       </div>
 
       {/* Filters */}
-      <div className="flex items-center gap-4 mb-6">
+      <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
         <select
-          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
           value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
           onChange={(e) => {
             const val = e.target.value;
@@ -1079,7 +1087,7 @@ export function QueuePage() {
         </select>
 
         <select
-          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+          className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
           value={filterStatus}
           onChange={(e) => setFilterStatus(e.target.value)}
         >
@@ -1094,7 +1102,7 @@ export function QueuePage() {
 
         {uniqueLocations.length > 0 && (
           <select
-            className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
             value={filterLocation}
             onChange={(e) => setFilterLocation(e.target.value)}
           >
@@ -1105,10 +1113,11 @@ export function QueuePage() {
           </select>
         )}
 
-        <div className="flex-1" />
+        <div className="hidden sm:block flex-1" />
 
         {historyItems.length > 0 && (
           <Button
+            className="w-full sm:w-auto"
             variant="secondary"
             size="sm"
             onClick={() => setShowClearHistoryConfirm(true)}
@@ -1132,15 +1141,15 @@ export function QueuePage() {
           </p>
         </Card>
       ) : (
-        <div className="space-y-8">
+        <div className="space-y-6 sm:space-y-8">
           {/* Active Prints */}
           {activeItems.length > 0 && (
             <div>
-              <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
+              <h2 className="text-base sm:text-lg font-semibold text-white mb-3 sm:mb-4 flex items-center gap-2">
                 <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
                 {t('queue.sections.currentlyPrinting')}
               </h2>
-              <div className="space-y-3">
+              <div className="space-y-2 sm:space-y-3">
                 {activeItems.map((item) => (
                   <SortableQueueItem
                     key={item.id}
@@ -1165,20 +1174,20 @@ export function QueuePage() {
           {/* Pending Queue */}
           {pendingItems.length > 0 && (
             <div>
-              <div className="flex items-center justify-between mb-4">
-                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-                  <Clock className="w-5 h-5 text-yellow-400" />
+              <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
+                <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
+                  <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400" />
                   {t('queue.sections.queued')}
-                  <span className="text-sm font-normal text-bambu-gray">
+                  <span className="text-xs sm:text-sm font-normal text-bambu-gray">
                     ({t('queue.itemCount', { count: pendingItems.length })})
                   </span>
-                  <span className="text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
+                  <span className="hidden sm:inline text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
                     {t('queue.dragToReorder')}
                   </span>
                 </h2>
                 <div className="flex items-center gap-2">
                   <select
-                    className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     value={pendingSortBy}
                     onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
                   >
@@ -1200,12 +1209,12 @@ export function QueuePage() {
               </div>
 
               {/* Bulk action toolbar */}
-              <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-dark rounded-lg">
+              <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-3 sm:mb-4 p-2 sm:p-3 bg-bambu-dark rounded-lg">
                 <Button
                   variant="ghost"
                   size="sm"
                   onClick={handleSelectAll}
-                  className="flex items-center gap-2"
+                  className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm"
                 >
                   {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? (
                     <CheckSquare className="w-4 h-4 text-bambu-green" />
@@ -1216,31 +1225,31 @@ export function QueuePage() {
                 </Button>
                 {selectedItems.length > 0 && (
                   <>
-                    <span className="text-sm text-bambu-gray">
+                    <span className="text-xs sm:text-sm text-bambu-gray">
                       {t('queue.bulkEdit.selected', { count: selectedItems.length })}
                     </span>
-                    <div className="h-4 w-px bg-bambu-dark-tertiary" />
+                    <div className="hidden sm:block h-4 w-px bg-bambu-dark-tertiary" />
                     <Button
                       variant="ghost"
                       size="sm"
                       onClick={() => setShowBulkEditModal(true)}
-                      className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
+                      className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-bambu-green hover:text-bambu-green-light"
                       disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
-                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : undefined}
+                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : t('queue.bulkEdit.editSelected')}
                     >
-                      <Pencil className="w-4 h-4" />
-                      {t('queue.bulkEdit.editSelected')}
+                      <Pencil className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
+                      <span className="hidden sm:inline">{t('queue.bulkEdit.editSelected')}</span>
                     </Button>
                     <Button
                       variant="ghost"
                       size="sm"
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
-                      className="flex items-center gap-2 text-red-400 hover:text-red-300"
+                      className="flex items-center gap-1.5 sm:gap-2 text-xs sm:text-sm text-red-400 hover:text-red-300"
                       disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
-                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : undefined}
+                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : t('queue.bulkEdit.cancelSelected')}
                     >
-                      <X className="w-4 h-4" />
-                      {t('queue.bulkEdit.cancelSelected')}
+                      <X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
+                      <span className="hidden sm:inline">{t('queue.bulkEdit.cancelSelected')}</span>
                     </Button>
                   </>
                 )}
@@ -1255,7 +1264,7 @@ export function QueuePage() {
                   items={pendingItems.map(i => i.id)}
                   strategy={verticalListSortingStrategy}
                 >
-                  <div className="space-y-3">
+                  <div className="space-y-2 sm:space-y-3">
                     {pendingItems.map((item, index) => (
                       <SortableQueueItem
                         key={item.id}
@@ -1284,17 +1293,17 @@ export function QueuePage() {
           {/* History */}
           {historyItems.length > 0 && (
             <div>
-              <div className="flex items-center justify-between mb-4">
-                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-                  <CheckCircle className="w-5 h-5 text-bambu-gray" />
+              <div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
+                <h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
+                  <CheckCircle className="w-4 h-4 sm:w-5 sm:h-5 text-bambu-gray" />
                   {t('queue.sections.history')}
-                  <span className="text-sm font-normal text-bambu-gray">
+                  <span className="text-xs sm:text-sm font-normal text-bambu-gray">
                     ({t('queue.itemCount', { count: historyItems.length })})
                   </span>
                 </h2>
                 <div className="flex items-center gap-2">
                   <select
-                    className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     value={historySortBy}
                     onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
                   >
@@ -1313,7 +1322,7 @@ export function QueuePage() {
                   </Button>
                 </div>
               </div>
-              <div className="space-y-3">
+              <div className="space-y-2 sm:space-y-3">
                 {historyItems.slice(0, 20).map((item, index) => (
                   <SortableQueueItem
                     key={item.id}

+ 64 - 3
frontend/src/utils/date.ts

@@ -392,7 +392,7 @@ export function formatETA(
 ): string {
   const now = new Date();
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
-  
+
   const today = new Date();
   today.setHours(0, 0, 0, 0);
   const etaDay = new Date(eta);
@@ -418,9 +418,70 @@ export function formatETA(
  */
 export function formatDuration(seconds: number | null | undefined): string {
   if (seconds == null || seconds < 0) return '--';
-  
+
   const hours = Math.floor(seconds / 3600);
   const minutes = Math.floor((seconds % 3600) / 60);
-  
+
   return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
 }
+
+type TranslateFunction = (key: string, options?: Record<string, unknown>) => string;
+
+/**
+ * Format a date string as a human-readable relative time expression.
+ *
+ * @param dateStr - UTC date string, or null
+ * @param timeFormat - Time format preference ('12h', '24h', or 'system')
+ * @param t - Optional translation function for i18n support
+ * @returns Relative string (e.g., "5m ago", "in 2h", "3d ago") or formatted date if older than 7 days
+ */
+export function formatRelativeTime(
+  dateStr: string | null,
+  timeFormat: TimeFormat = 'system',
+  t?: TranslateFunction
+): string {
+  if (!dateStr) return t?.('time.unknown') ?? '-';
+
+  const date = parseUTCDate(dateStr);
+  if (!date) return t?.('time.unknown') ?? '-';
+
+  const now = new Date();
+  const diffMs = date.getTime() - now.getTime();
+  const isPast = diffMs < 0;
+  const absDiffMs = Math.abs(diffMs);
+
+  const minutes = Math.floor(absDiffMs / 60000);
+  const hours = Math.floor(absDiffMs / 3600000);
+  const days = Math.floor(absDiffMs / 86400000);
+
+  // Less than 1 minute
+  if (minutes < 1) {
+    return isPast
+      ? t?.('time.justNow') ?? 'Just now'
+      : t?.('time.now') ?? 'Now';
+  }
+
+  // Less than 1 hour
+  if (hours < 1) {
+    return isPast
+      ? t?.('time.minsAgo', { count: minutes }) ?? `${minutes}m ago`
+      : t?.('time.inMins', { count: minutes }) ?? `in ${minutes}m`;
+  }
+
+  // Less than 1 day
+  if (days < 1) {
+    return isPast
+      ? t?.('time.hoursAgo', { count: hours }) ?? `${hours}h ago`
+      : t?.('time.inHours', { count: hours }) ?? `in ${hours}h`;
+  }
+
+  // Less than 7 days
+  if (days < 7) {
+    return isPast
+      ? t?.('time.daysAgo', { count: days }) ?? `${days}d ago`
+      : t?.('time.inDays', { count: days }) ?? `in ${days}d`;
+  }
+
+  // Older than 7 days
+  return formatDateTime(dateStr, timeFormat);
+}

+ 20 - 0
frontend/src/utils/file.ts

@@ -0,0 +1,20 @@
+/**
+ * Formats a byte count into a human-readable string (e.g. `1.5 MB`).
+ *
+ * @param bytes - The number of bytes to format.
+ * @returns A formatted string with the appropriate unit (B, KB, MB, GB, or TB).
+ */
+export function formatFileSize(bytes: number): string {
+  if (bytes === 0) return '0 B';
+
+  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+  const k = 1024;
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+  const size = bytes / Math.pow(k, i);
+
+  // No decimals for bytes, 1 decimal for larger units
+  return i === 0
+    ? `${size} ${units[i]}`
+    : `${size.toFixed(1)} ${units[i]}`;
+}

+ 30 - 10
frontend/src/utils/slicer.ts

@@ -1,12 +1,23 @@
 /**
  * Utility for opening files in slicer applications
  *
- * Bambu Studio URL protocol is OS-specific:
- * - Windows: bambustudio://<encoded-URL>
- * - macOS/Linux: bambustudioopen://<encoded-URL>
+ * Protocol handler URL formats (from BambuStudio/OrcaSlicer source code):
  *
- * OrcaSlicer uses the same protocol on all platforms:
- * - orcaslicer://open?file=<URL>
+ * Bambu Studio has TWO separate URL handlers:
+ *   1. post_init() [Windows/Linux CLI args]: bambustudio://open?file=<URL>
+ *      - Checks: starts_with("bambustudio://open")
+ *      - Calls url_decode(), then split_str(url, "file=")
+ *   2. MacOpenURL() [macOS Apple Events]: bambustudioopen://<encoded-URL>
+ *      - Checks: starts_with("bambustudioopen://")
+ *      - Strips prefix, then url_decode()
+ *
+ * OrcaSlicer Downloader accepts both formats via regex:
+ *   - (orcaslicer|bambustudio|...)://open?file=<URL>
+ *   - bambustudioopen://<URL>
+ *
+ * Key insight: Using ?file= query format, the browser's URL parser preserves
+ * http:// in the query string without any encoding. Only the macOS-specific
+ * bambustudioopen:// format needs encodeURIComponent (BS calls url_decode).
  */
 
 export type SlicerType = 'bambu_studio' | 'orcaslicer';
@@ -34,8 +45,6 @@ export function detectPlatform(): Platform {
 
 /**
  * Open a URL in the specified slicer application.
- * Uses a temporary link element to trigger the protocol handler,
- * which avoids browser "unknown protocol" blocks on window.location.href.
  * @param downloadUrl - The URL to the file to open
  * @param slicer - Which slicer to use (defaults to bambu_studio)
  */
@@ -43,15 +52,26 @@ export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_st
   let url: string;
 
   if (slicer === 'orcaslicer') {
+    // OrcaSlicer: ?file= query format — http:// preserved in query string
     url = `orcaslicer://open?file=${downloadUrl}`;
   } else {
     const platform = detectPlatform();
-    const protocol = platform === 'windows' ? 'bambustudio' : 'bambustudioopen';
-    url = `${protocol}://${encodeURIComponent(downloadUrl)}`;
+    if (platform === 'macos') {
+      // macOS only: bambustudioopen scheme via MacOpenURL() callback.
+      // Must encode because bare http:// in authority gets mangled by browser.
+      // BS calls url_decode() after stripping "bambustudioopen://" prefix.
+      url = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+    } else {
+      // Windows/Linux: bambustudio://open?file= via post_init() CLI args.
+      // The ?file= query format preserves http:// without encoding.
+      // IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
+      // it does NOT process "bambustudioopen://" (that's macOS-only).
+      url = `bambustudio://open?file=${downloadUrl}`;
+    }
   }
 
   // Use a temporary <a> element to trigger the protocol handler.
-  // This works more reliably than window.location.href for custom protocols.
+  // This avoids navigating away from the page (unlike window.location.href).
   const link = document.createElement('a');
   link.href = url;
   link.style.display = 'none';

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DF7TfzH1.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Dnmq8_Ro.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-EqFdfChN.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-D8lA4NE5.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DF7TfzH1.css">
+    <script type="module" crossorigin src="/assets/index-Dnmq8_Ro.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-EqFdfChN.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff